Seth commited on
Commit
e9a187e
·
1 Parent(s): d9926cf
backend/app/tenant_routes.py CHANGED
@@ -104,3 +104,65 @@ def create_invitation(body: InviteBody, tc: TenantContext = Depends(require_tena
104
  "expires_at": exp.isoformat() + "Z",
105
  "email": email_n,
106
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  "expires_at": exp.isoformat() + "Z",
105
  "email": email_n,
106
  }
107
+
108
+
109
+ @router.get("/members")
110
+ def list_tenant_members(tc: TenantContext = Depends(require_tenant_admin)):
111
+ """List users in the current workspace (admin only)."""
112
+ db = tc.db
113
+ rows = (
114
+ db.query(TenantMembership, User)
115
+ .join(User, User.id == TenantMembership.user_id)
116
+ .filter(TenantMembership.tenant_id == tc.tenant_id)
117
+ .order_by(User.email)
118
+ .all()
119
+ )
120
+ members = []
121
+ for m, u in rows:
122
+ members.append(
123
+ {
124
+ "user_id": u.id,
125
+ "email": u.email or "",
126
+ "name": u.name or "",
127
+ "role": m.role,
128
+ }
129
+ )
130
+ return {"members": members}
131
+
132
+
133
+ @router.delete("/members/{member_user_id}")
134
+ def remove_tenant_member(
135
+ member_user_id: int,
136
+ tc: TenantContext = Depends(require_tenant_admin),
137
+ ):
138
+ """Remove a user from the workspace. Cannot remove yourself or the last admin."""
139
+ if member_user_id == tc.user_id:
140
+ raise HTTPException(status_code=400, detail="Cannot remove yourself from the workspace")
141
+
142
+ db = tc.db
143
+ target = (
144
+ db.query(TenantMembership)
145
+ .filter(
146
+ TenantMembership.tenant_id == tc.tenant_id,
147
+ TenantMembership.user_id == member_user_id,
148
+ )
149
+ .first()
150
+ )
151
+ if not target:
152
+ raise HTTPException(status_code=404, detail="Member not found in this workspace")
153
+
154
+ if target.role == "admin":
155
+ admin_count = (
156
+ db.query(func.count(TenantMembership.id))
157
+ .filter(
158
+ TenantMembership.tenant_id == tc.tenant_id,
159
+ TenantMembership.role == "admin",
160
+ )
161
+ .scalar()
162
+ )
163
+ if admin_count is not None and int(admin_count) <= 1:
164
+ raise HTTPException(status_code=400, detail="Cannot remove the last admin")
165
+
166
+ db.delete(target)
167
+ db.commit()
168
+ return {"ok": True}
frontend/src/App.jsx CHANGED
@@ -4,6 +4,7 @@ import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
6
  import Deals from "./pages/Deals";
 
7
  import "./index.css";
8
 
9
  export default function App() {
@@ -14,6 +15,7 @@ export default function App() {
14
  <Route path="/contacts" element={<Contacts />} />
15
  <Route path="/leads" element={<Leads />} />
16
  <Route path="/deals" element={<Deals />} />
 
17
  <Route path="/history" element={<Navigate to="/leads" replace />} />
18
  </Routes>
19
  </BrowserRouter>
 
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
6
  import Deals from "./pages/Deals";
7
+ import Settings from "./pages/Settings";
8
  import "./index.css";
9
 
10
  export default function App() {
 
15
  <Route path="/contacts" element={<Contacts />} />
16
  <Route path="/leads" element={<Leads />} />
17
  <Route path="/deals" element={<Deals />} />
18
+ <Route path="/settings" element={<Settings />} />
19
  <Route path="/history" element={<Navigate to="/leads" replace />} />
20
  </Routes>
21
  </BrowserRouter>
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
2
  import { Link, useLocation } from 'react-router-dom';
3
  import {
4
  Zap,
@@ -8,12 +8,14 @@ import {
8
  Handshake,
9
  ChevronLeft,
10
  ChevronRight,
 
11
  } from 'lucide-react';
12
  import { Button } from '@/components/ui/button';
13
  import { cn } from '@/lib/utils';
 
14
  import GoogleAuthBar from '@/components/layout/GoogleAuthBar';
15
 
16
- const NAV_ITEMS = [
17
  { label: 'Generator', href: '/', icon: LayoutDashboard },
18
  { label: 'Contacts', href: '/contacts', icon: Users },
19
  { label: 'Leads', href: '/leads', icon: Inbox },
@@ -29,6 +31,31 @@ function pathMatches(locationPath, href) {
29
 
30
  export default function AppShell({ title, subtitle, rightContent, children }) {
31
  const location = useLocation();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
33
  try {
34
  return typeof window !== 'undefined' && localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
@@ -88,8 +115,8 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
88
  </div>
89
  </div>
90
  </div>
91
- <nav className="flex items-center gap-2 border-t border-slate-100 bg-white/90 px-4 py-2 md:hidden">
92
- {NAV_ITEMS.map((item) => {
93
  const active = pathMatches(location.pathname, item.href);
94
  return (
95
  <Button
@@ -119,7 +146,7 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
119
  sidebarCollapsed && 'items-center'
120
  )}
121
  >
122
- {NAV_ITEMS.map((item) => {
123
  const Icon = item.icon;
124
  const active = pathMatches(location.pathname, item.href);
125
  return (
 
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
  import { Link, useLocation } from 'react-router-dom';
3
  import {
4
  Zap,
 
8
  Handshake,
9
  ChevronLeft,
10
  ChevronRight,
11
+ Settings,
12
  } from 'lucide-react';
13
  import { Button } from '@/components/ui/button';
14
  import { cn } from '@/lib/utils';
15
+ import { apiFetch } from '@/lib/api';
16
  import GoogleAuthBar from '@/components/layout/GoogleAuthBar';
17
 
18
+ const BASE_NAV_ITEMS = [
19
  { label: 'Generator', href: '/', icon: LayoutDashboard },
20
  { label: 'Contacts', href: '/contacts', icon: Users },
21
  { label: 'Leads', href: '/leads', icon: Inbox },
 
31
 
32
  export default function AppShell({ title, subtitle, rightContent, children }) {
33
  const location = useLocation();
34
+ const [showSettingsNav, setShowSettingsNav] = useState(false);
35
+
36
+ useEffect(() => {
37
+ const syncAdminNav = () => {
38
+ apiFetch('/api/auth/me')
39
+ .then((r) => (r.ok ? r.json() : null))
40
+ .then((u) => setShowSettingsNav(u?.current_role === 'admin'))
41
+ .catch(() => setShowSettingsNav(false));
42
+ };
43
+ syncAdminNav();
44
+ window.addEventListener('emailout-auth-changed', syncAdminNav);
45
+ return () => window.removeEventListener('emailout-auth-changed', syncAdminNav);
46
+ }, []);
47
+
48
+ const navItems = useMemo(
49
+ () =>
50
+ showSettingsNav
51
+ ? [
52
+ ...BASE_NAV_ITEMS,
53
+ { label: 'Settings', href: '/settings', icon: Settings },
54
+ ]
55
+ : BASE_NAV_ITEMS,
56
+ [showSettingsNav]
57
+ );
58
+
59
  const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
60
  try {
61
  return typeof window !== 'undefined' && localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
 
115
  </div>
116
  </div>
117
  </div>
118
+ <nav className="flex items-center gap-2 border-t border-slate-100 bg-white/90 px-4 py-2 md:hidden flex-wrap">
119
+ {navItems.map((item) => {
120
  const active = pathMatches(location.pathname, item.href);
121
  return (
122
  <Button
 
146
  sidebarCollapsed && 'items-center'
147
  )}
148
  >
149
+ {navItems.map((item) => {
150
  const Icon = item.icon;
151
  const active = pathMatches(location.pathname, item.href);
152
  return (
frontend/src/components/layout/GoogleAuthBar.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import React, { useCallback, useEffect, useState } from 'react';
2
  import { useSearchParams } from 'react-router-dom';
3
- import { LogOut, Building2, UserPlus } from 'lucide-react';
4
  import { Button } from '@/components/ui/button';
5
  import {
6
  Select,
@@ -9,23 +9,17 @@ import {
9
  SelectTrigger,
10
  SelectValue,
11
  } from '@/components/ui/select';
12
- import { Input } from '@/components/ui/input';
13
  import { cn } from '@/lib/utils';
14
  import { apiFetch } from '@/lib/api';
15
 
16
  /**
17
- * Sign-in (Google), workspace switcher, admin invitations; session cookie from OAuth callback.
18
  */
19
  export default function GoogleAuthBar() {
20
  const [searchParams, setSearchParams] = useSearchParams();
21
  const [phase, setPhase] = useState('loading');
22
  const [googleOn, setGoogleOn] = useState(false);
23
  const [user, setUser] = useState(null);
24
- const [inviteOpen, setInviteOpen] = useState(false);
25
- const [inviteEmail, setInviteEmail] = useState('');
26
- const [inviteBusy, setInviteBusy] = useState(false);
27
- const [inviteResult, setInviteResult] = useState(null);
28
-
29
  const refresh = useCallback(async () => {
30
  try {
31
  const [st, me] = await Promise.all([
@@ -35,6 +29,9 @@ export default function GoogleAuthBar() {
35
  setGoogleOn(!!st.googleConfigured);
36
  setUser(me);
37
  setPhase('ready');
 
 
 
38
  } catch {
39
  setPhase('error');
40
  }
@@ -65,6 +62,9 @@ export default function GoogleAuthBar() {
65
  try {
66
  await apiFetch('/api/auth/logout', { method: 'POST' });
67
  setUser(null);
 
 
 
68
  } catch (e) {
69
  console.error(e);
70
  }
@@ -86,33 +86,6 @@ export default function GoogleAuthBar() {
86
  }
87
  };
88
 
89
- const sendInvite = async () => {
90
- const email = inviteEmail.trim().toLowerCase();
91
- if (!email || !email.includes('@')) return;
92
- setInviteBusy(true);
93
- setInviteResult(null);
94
- try {
95
- const res = await apiFetch('/api/tenants/invite', {
96
- method: 'POST',
97
- headers: { 'Content-Type': 'application/json' },
98
- body: JSON.stringify({ email, role: 'member' }),
99
- });
100
- const data = await res.json().catch(() => ({}));
101
- if (!res.ok) {
102
- setInviteResult({
103
- error: typeof data.detail === 'string' ? data.detail : 'Invite failed',
104
- });
105
- return;
106
- }
107
- setInviteResult({ url: data.invite_url });
108
- setInviteEmail('');
109
- } catch (e) {
110
- setInviteResult({ error: String(e) });
111
- } finally {
112
- setInviteBusy(false);
113
- }
114
- };
115
-
116
  const inviteParam = searchParams.get('invite');
117
  const googleHref = inviteParam
118
  ? `/api/auth/google?invite=${encodeURIComponent(inviteParam)}`
@@ -135,7 +108,6 @@ export default function GoogleAuthBar() {
135
  }
136
 
137
  if (user) {
138
- const isAdmin = user.current_role === 'admin';
139
  const tenants = user.tenants || [];
140
 
141
  return (
@@ -161,58 +133,6 @@ export default function GoogleAuthBar() {
161
  </Select>
162
  </div>
163
  ) : null}
164
- {isAdmin ? (
165
- <>
166
- <Button
167
- type="button"
168
- variant="outline"
169
- size="sm"
170
- className="h-9 gap-1 shrink-0"
171
- onClick={() => {
172
- setInviteOpen((o) => !o);
173
- setInviteResult(null);
174
- }}
175
- >
176
- <UserPlus className="h-3.5 w-3.5" />
177
- <span className="hidden sm:inline">Invite</span>
178
- </Button>
179
- {inviteOpen ? (
180
- <div className="absolute right-4 top-full z-50 mt-1 w-[min(100vw-2rem,22rem)] rounded-lg border border-slate-200 bg-white p-3 shadow-lg">
181
- <p className="text-xs text-slate-600 mb-2">
182
- Invite a Google user by email. They must sign in with that Google
183
- account.
184
- </p>
185
- <div className="flex gap-2">
186
- <Input
187
- type="email"
188
- placeholder="colleague@company.com"
189
- value={inviteEmail}
190
- onChange={(e) => setInviteEmail(e.target.value)}
191
- className="h-9 text-sm"
192
- />
193
- <Button
194
- type="button"
195
- size="sm"
196
- className="shrink-0"
197
- disabled={inviteBusy}
198
- onClick={sendInvite}
199
- >
200
- {inviteBusy ? '…' : 'Send'}
201
- </Button>
202
- </div>
203
- {inviteResult?.error ? (
204
- <p className="text-xs text-red-600 mt-2">{inviteResult.error}</p>
205
- ) : null}
206
- {inviteResult?.url ? (
207
- <div className="mt-2 space-y-1">
208
- <p className="text-xs text-slate-600">Invite link (7 days):</p>
209
- <Input readOnly value={inviteResult.url} className="text-xs h-8" />
210
- </div>
211
- ) : null}
212
- </div>
213
- ) : null}
214
- </>
215
- ) : null}
216
  {user.picture ? (
217
  <img
218
  src={user.picture}
 
1
  import React, { useCallback, useEffect, useState } from 'react';
2
  import { useSearchParams } from 'react-router-dom';
3
+ import { LogOut, Building2 } from 'lucide-react';
4
  import { Button } from '@/components/ui/button';
5
  import {
6
  Select,
 
9
  SelectTrigger,
10
  SelectValue,
11
  } from '@/components/ui/select';
 
12
  import { cn } from '@/lib/utils';
13
  import { apiFetch } from '@/lib/api';
14
 
15
  /**
16
+ * Sign-in (Google), workspace switcher; invitations live on Settings (admins). Session from OAuth callback.
17
  */
18
  export default function GoogleAuthBar() {
19
  const [searchParams, setSearchParams] = useSearchParams();
20
  const [phase, setPhase] = useState('loading');
21
  const [googleOn, setGoogleOn] = useState(false);
22
  const [user, setUser] = useState(null);
 
 
 
 
 
23
  const refresh = useCallback(async () => {
24
  try {
25
  const [st, me] = await Promise.all([
 
29
  setGoogleOn(!!st.googleConfigured);
30
  setUser(me);
31
  setPhase('ready');
32
+ if (typeof window !== 'undefined') {
33
+ window.dispatchEvent(new CustomEvent('emailout-auth-changed'));
34
+ }
35
  } catch {
36
  setPhase('error');
37
  }
 
62
  try {
63
  await apiFetch('/api/auth/logout', { method: 'POST' });
64
  setUser(null);
65
+ if (typeof window !== 'undefined') {
66
+ window.dispatchEvent(new CustomEvent('emailout-auth-changed'));
67
+ }
68
  } catch (e) {
69
  console.error(e);
70
  }
 
86
  }
87
  };
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  const inviteParam = searchParams.get('invite');
90
  const googleHref = inviteParam
91
  ? `/api/auth/google?invite=${encodeURIComponent(inviteParam)}`
 
108
  }
109
 
110
  if (user) {
 
111
  const tenants = user.tenants || [];
112
 
113
  return (
 
133
  </Select>
134
  </div>
135
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  {user.picture ? (
137
  <img
138
  src={user.picture}
frontend/src/pages/Leads.jsx CHANGED
@@ -11,7 +11,6 @@ import {
11
  Trash2,
12
  Handshake,
13
  Pencil,
14
- ClipboardCopy,
15
  } from 'lucide-react';
16
  import { Button } from '@/components/ui/button';
17
  import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
@@ -73,26 +72,6 @@ export default function Leads() {
73
  const [rowSelection, setRowSelection] = useState({});
74
  const [bulkBusy, setBulkBusy] = useState(null);
75
  const [tableEditRowId, setTableEditRowId] = useState(null);
76
- /** undefined = loading /auth/me; null = signed out or no tenant; number = workspace id for webhook */
77
- const [webhookTenantId, setWebhookTenantId] = useState(undefined);
78
-
79
- useEffect(() => {
80
- let cancelled = false;
81
- apiFetch('/api/auth/me').then(async (r) => {
82
- if (cancelled) return;
83
- if (!r.ok) {
84
- setWebhookTenantId(null);
85
- return;
86
- }
87
- const u = await r.json().catch(() => null);
88
- if (cancelled) return;
89
- const tid = u?.current_tenant_id;
90
- setWebhookTenantId(tid != null ? Number(tid) : null);
91
- });
92
- return () => {
93
- cancelled = true;
94
- };
95
- }, []);
96
 
97
  const selectedIds = useMemo(
98
  () => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
@@ -101,15 +80,6 @@ export default function Leads() {
101
 
102
  const allPageSelected = leads.length > 0 && leads.every((l) => rowSelection[l.id]);
103
 
104
- const webhookUrl = useMemo(() => {
105
- if (typeof window === 'undefined') return '';
106
- const base = `${window.location.origin}/api/webhooks/smartlead`;
107
- if (typeof webhookTenantId === 'number' && !Number.isNaN(webhookTenantId)) {
108
- return `${base}?tenant_id=${webhookTenantId}`;
109
- }
110
- return base;
111
- }, [webhookTenantId]);
112
-
113
  const fetchLeads = useCallback(async () => {
114
  setLoading(true);
115
  try {
@@ -403,33 +373,7 @@ export default function Leads() {
403
  return (
404
  <AppShell
405
  title="Leads"
406
- subtitle={
407
- <>
408
- Replies from Smartlead campaigns appear here. Webhook URL (this workspace):{' '}
409
- {webhookTenantId === undefined ? (
410
- <Loader2 className="inline h-3.5 w-3.5 animate-spin align-middle text-slate-400" />
411
- ) : (
412
- <span className="inline-flex max-w-full flex-wrap items-center gap-1 align-middle">
413
- <code className="text-xs bg-slate-100 px-1.5 py-0.5 rounded break-all">
414
- {webhookUrl}
415
- </code>
416
- <Button
417
- type="button"
418
- variant="outline"
419
- size="sm"
420
- className="h-7 shrink-0 gap-1 px-2 text-xs"
421
- title="Copy webhook URL"
422
- onClick={() => {
423
- if (webhookUrl) navigator.clipboard.writeText(webhookUrl);
424
- }}
425
- >
426
- <ClipboardCopy className="h-3.5 w-3.5" />
427
- Copy
428
- </Button>
429
- </span>
430
- )}
431
- </>
432
- }
433
  >
434
  <MainTableWorkspace
435
  primaryAction={{ label: 'New lead', to: '/' }}
@@ -474,8 +418,8 @@ export default function Leads() {
474
  ) : leads.length === 0 ? (
475
  <div className="text-center py-16 text-slate-500 space-y-3">
476
  <p>
477
- No leads yet. When a prospect replies in Smartlead, they will show up here via
478
- webhook.
479
  </p>
480
  <Button size="sm" variant="secondary" onClick={() => seedDemoLeads()} disabled={seedBusy}>
481
  Load demo rows (preview UI)
 
11
  Trash2,
12
  Handshake,
13
  Pencil,
 
14
  } from 'lucide-react';
15
  import { Button } from '@/components/ui/button';
16
  import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
 
72
  const [rowSelection, setRowSelection] = useState({});
73
  const [bulkBusy, setBulkBusy] = useState(null);
74
  const [tableEditRowId, setTableEditRowId] = useState(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  const selectedIds = useMemo(
77
  () => Object.keys(rowSelection).filter((id) => rowSelection[id]).map(Number),
 
80
 
81
  const allPageSelected = leads.length > 0 && leads.every((l) => rowSelection[l.id]);
82
 
 
 
 
 
 
 
 
 
 
83
  const fetchLeads = useCallback(async () => {
84
  setLoading(true);
85
  try {
 
373
  return (
374
  <AppShell
375
  title="Leads"
376
+ subtitle="Replies from Smartlead campaigns appear here. Configure the webhook URL under Settings (admins)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  >
378
  <MainTableWorkspace
379
  primaryAction={{ label: 'New lead', to: '/' }}
 
418
  ) : leads.length === 0 ? (
419
  <div className="text-center py-16 text-slate-500 space-y-3">
420
  <p>
421
+ No leads yet. When a prospect replies in Smartlead, they will show up here once the
422
+ webhook URL from Settings is configured in Smartlead.
423
  </p>
424
  <Button size="sm" variant="secondary" onClick={() => seedDemoLeads()} disabled={seedBusy}>
425
  Load demo rows (preview UI)
frontend/src/pages/Settings.jsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import {
4
+ ClipboardCopy,
5
+ Loader2,
6
+ ShieldAlert,
7
+ UserPlus,
8
+ Users,
9
+ Building2,
10
+ Trash2,
11
+ } from 'lucide-react';
12
+ import AppShell from '@/components/layout/AppShell';
13
+ import { Button } from '@/components/ui/button';
14
+ import { Input } from '@/components/ui/input';
15
+ import { apiFetch } from '@/lib/api';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ export default function Settings() {
19
+ const [me, setMe] = useState(null);
20
+ const [meLoading, setMeLoading] = useState(true);
21
+ const [members, setMembers] = useState([]);
22
+ const [membersLoading, setMembersLoading] = useState(true);
23
+ const [membersError, setMembersError] = useState('');
24
+
25
+ const [inviteEmail, setInviteEmail] = useState('');
26
+ const [inviteBusy, setInviteBusy] = useState(false);
27
+ const [inviteResult, setInviteResult] = useState(null);
28
+
29
+ const [removeBusyId, setRemoveBusyId] = useState(null);
30
+
31
+ const loadMe = useCallback(async () => {
32
+ setMeLoading(true);
33
+ try {
34
+ const r = await apiFetch('/api/auth/me');
35
+ setMe(r.ok ? await r.json() : null);
36
+ } catch {
37
+ setMe(null);
38
+ } finally {
39
+ setMeLoading(false);
40
+ }
41
+ }, []);
42
+
43
+ const loadMembers = useCallback(async () => {
44
+ setMembersLoading(true);
45
+ setMembersError('');
46
+ try {
47
+ const r = await apiFetch('/api/tenants/members');
48
+ if (!r.ok) {
49
+ const data = await r.json().catch(() => ({}));
50
+ setMembersError(
51
+ typeof data.detail === 'string' ? data.detail : 'Could not load members'
52
+ );
53
+ setMembers([]);
54
+ return;
55
+ }
56
+ const data = await r.json();
57
+ setMembers(data.members || []);
58
+ } catch (e) {
59
+ setMembersError(String(e));
60
+ setMembers([]);
61
+ } finally {
62
+ setMembersLoading(false);
63
+ }
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ loadMe();
68
+ }, [loadMe]);
69
+
70
+ const isAdmin = me?.current_role === 'admin';
71
+
72
+ useEffect(() => {
73
+ if (!meLoading && isAdmin) {
74
+ loadMembers();
75
+ }
76
+ }, [meLoading, isAdmin, loadMembers]);
77
+
78
+ const currentTenant = useMemo(() => {
79
+ const tid = me?.current_tenant_id;
80
+ if (tid == null || !me?.tenants) return null;
81
+ return me.tenants.find((t) => Number(t.id) === Number(tid)) || null;
82
+ }, [me]);
83
+
84
+ const webhookUrl = useMemo(() => {
85
+ if (typeof window === 'undefined') return '';
86
+ const base = `${window.location.origin}/api/webhooks/smartlead`;
87
+ const tid = me?.current_tenant_id;
88
+ if (tid != null && !Number.isNaN(Number(tid))) {
89
+ return `${base}?tenant_id=${tid}`;
90
+ }
91
+ return base;
92
+ }, [me]);
93
+
94
+ const sendInvite = async () => {
95
+ const email = inviteEmail.trim().toLowerCase();
96
+ if (!email || !email.includes('@')) return;
97
+ setInviteBusy(true);
98
+ setInviteResult(null);
99
+ try {
100
+ const res = await apiFetch('/api/tenants/invite', {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({ email, role: 'member' }),
104
+ });
105
+ const data = await res.json().catch(() => ({}));
106
+ if (!res.ok) {
107
+ setInviteResult({
108
+ error: typeof data.detail === 'string' ? data.detail : 'Invite failed',
109
+ });
110
+ return;
111
+ }
112
+ setInviteResult({ url: data.invite_url });
113
+ setInviteEmail('');
114
+ } catch (e) {
115
+ setInviteResult({ error: String(e) });
116
+ } finally {
117
+ setInviteBusy(false);
118
+ }
119
+ };
120
+
121
+ const removeMember = async (userId) => {
122
+ if (!window.confirm('Remove this user from the workspace?')) return;
123
+ setRemoveBusyId(userId);
124
+ try {
125
+ const res = await apiFetch(`/api/tenants/members/${userId}`, { method: 'DELETE' });
126
+ const data = await res.json().catch(() => ({}));
127
+ if (!res.ok) {
128
+ alert(typeof data.detail === 'string' ? data.detail : 'Could not remove member');
129
+ return;
130
+ }
131
+ await loadMembers();
132
+ } catch (e) {
133
+ alert(e.message || 'Could not remove member');
134
+ } finally {
135
+ setRemoveBusyId(null);
136
+ }
137
+ };
138
+
139
+ if (meLoading) {
140
+ return (
141
+ <AppShell title="Settings">
142
+ <div className="flex justify-center py-24 text-slate-500">
143
+ <Loader2 className="h-10 w-10 animate-spin" />
144
+ </div>
145
+ </AppShell>
146
+ );
147
+ }
148
+
149
+ if (!me) {
150
+ return (
151
+ <AppShell title="Settings">
152
+ <div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
153
+ <p className="text-slate-600 mb-4">Sign in to manage workspace settings.</p>
154
+ <Button asChild variant="default">
155
+ <a href="/api/auth/google">Sign in with Google</a>
156
+ </Button>
157
+ </div>
158
+ </AppShell>
159
+ );
160
+ }
161
+
162
+ if (!isAdmin) {
163
+ return (
164
+ <AppShell title="Settings">
165
+ <div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 flex gap-4 items-start">
166
+ <ShieldAlert className="h-8 w-8 shrink-0 text-amber-700" />
167
+ <div>
168
+ <h3 className="font-semibold text-amber-950">Admin access required</h3>
169
+ <p className="text-sm text-amber-900/90 mt-1">
170
+ Invitations, member management, and workspace integration URLs are only
171
+ available to workspace admins. Ask an admin to grant you the admin role or
172
+ use another workspace where you are an admin.
173
+ </p>
174
+ <Button asChild variant="outline" className="mt-4">
175
+ <Link to="/">Back to Generator</Link>
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ </AppShell>
180
+ );
181
+ }
182
+
183
+ return (
184
+ <AppShell
185
+ title="Settings"
186
+ subtitle="Workspace details, Smartlead webhook, invitations, and members (admins only)."
187
+ >
188
+ <div className="space-y-8 max-w-3xl">
189
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
190
+ <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
191
+ <Building2 className="h-5 w-5 text-violet-600" />
192
+ Workspace details
193
+ </div>
194
+ <dl className="grid gap-3 text-sm sm:grid-cols-[8rem_1fr] sm:gap-x-4">
195
+ <dt className="text-slate-500">Name</dt>
196
+ <dd className="text-slate-900 font-medium">
197
+ {currentTenant?.name ?? '—'}
198
+ </dd>
199
+ <dt className="text-slate-500">Workspace ID</dt>
200
+ <dd className="font-mono text-slate-900">{me.current_tenant_id ?? '—'}</dd>
201
+ <dt className="text-slate-500">Your role</dt>
202
+ <dd className="capitalize text-slate-900">{me.current_role}</dd>
203
+ </dl>
204
+ <div className="mt-6 pt-6 border-t border-slate-100">
205
+ <p className="text-xs font-medium text-slate-600 mb-2">
206
+ Smartlead webhook URL
207
+ </p>
208
+ <p className="text-xs text-slate-500 mb-2">
209
+ Paste this URL in Smartlead so reply events are stored for this workspace.
210
+ </p>
211
+ <div className="flex flex-wrap items-center gap-2">
212
+ <code className="text-xs bg-slate-100 px-2 py-1.5 rounded break-all flex-1 min-w-[12rem]">
213
+ {webhookUrl}
214
+ </code>
215
+ <Button
216
+ type="button"
217
+ variant="outline"
218
+ size="sm"
219
+ className="shrink-0 gap-1"
220
+ onClick={() => webhookUrl && navigator.clipboard.writeText(webhookUrl)}
221
+ >
222
+ <ClipboardCopy className="h-3.5 w-3.5" />
223
+ Copy
224
+ </Button>
225
+ </div>
226
+ </div>
227
+ </section>
228
+
229
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
230
+ <div className="flex items-center gap-2 text-slate-800 font-semibold mb-2">
231
+ <UserPlus className="h-5 w-5 text-violet-600" />
232
+ Invite people
233
+ </div>
234
+ <p className="text-sm text-slate-600 mb-4">
235
+ Invite a Google user by email. They must sign in with that Google account.
236
+ </p>
237
+ <div className="flex flex-col sm:flex-row gap-2 max-w-xl">
238
+ <Input
239
+ type="email"
240
+ placeholder="colleague@company.com"
241
+ value={inviteEmail}
242
+ onChange={(e) => setInviteEmail(e.target.value)}
243
+ className="h-10"
244
+ />
245
+ <Button
246
+ type="button"
247
+ disabled={inviteBusy}
248
+ onClick={sendInvite}
249
+ className="shrink-0"
250
+ >
251
+ {inviteBusy ? (
252
+ <Loader2 className="h-4 w-4 animate-spin" />
253
+ ) : (
254
+ 'Create invite'
255
+ )}
256
+ </Button>
257
+ </div>
258
+ {inviteResult?.error ? (
259
+ <p className="text-sm text-red-600 mt-3">{inviteResult.error}</p>
260
+ ) : null}
261
+ {inviteResult?.url ? (
262
+ <div className="mt-4 space-y-2">
263
+ <p className="text-xs text-slate-600">Invite link (7 days):</p>
264
+ <Input readOnly value={inviteResult.url} className="text-xs h-9 font-mono" />
265
+ </div>
266
+ ) : null}
267
+ </section>
268
+
269
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
270
+ <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
271
+ <Users className="h-5 w-5 text-violet-600" />
272
+ Members
273
+ </div>
274
+ {membersLoading ? (
275
+ <div className="flex justify-center py-12 text-slate-400">
276
+ <Loader2 className="h-8 w-8 animate-spin" />
277
+ </div>
278
+ ) : membersError ? (
279
+ <p className="text-sm text-red-600">{membersError}</p>
280
+ ) : members.length === 0 ? (
281
+ <p className="text-sm text-slate-500">No members found.</p>
282
+ ) : (
283
+ <div className="overflow-x-auto rounded-xl border border-slate-100">
284
+ <table className="w-full text-sm text-left">
285
+ <thead>
286
+ <tr className="border-b border-slate-100 bg-slate-50/80">
287
+ <th className="px-4 py-3 font-medium text-slate-600">Email</th>
288
+ <th className="px-4 py-3 font-medium text-slate-600">Name</th>
289
+ <th className="px-4 py-3 font-medium text-slate-600">Role</th>
290
+ <th className="px-4 py-3 font-medium text-slate-600 w-24" />
291
+ </tr>
292
+ </thead>
293
+ <tbody>
294
+ {members.map((m) => {
295
+ const isSelf = m.user_id === me.user_id;
296
+ return (
297
+ <tr
298
+ key={m.user_id}
299
+ className="border-b border-slate-50 last:border-0"
300
+ >
301
+ <td className="px-4 py-3 text-slate-900">
302
+ {m.email || '—'}
303
+ </td>
304
+ <td className="px-4 py-3 text-slate-700">
305
+ {m.name || '—'}
306
+ </td>
307
+ <td className="px-4 py-3">
308
+ <span
309
+ className={cn(
310
+ 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
311
+ m.role === 'admin'
312
+ ? 'bg-violet-100 text-violet-800'
313
+ : 'bg-slate-100 text-slate-700'
314
+ )}
315
+ >
316
+ {m.role}
317
+ </span>
318
+ </td>
319
+ <td className="px-4 py-3 text-right">
320
+ {isSelf ? (
321
+ <span className="text-xs text-slate-400">
322
+ You
323
+ </span>
324
+ ) : (
325
+ <Button
326
+ type="button"
327
+ variant="ghost"
328
+ size="sm"
329
+ className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
330
+ disabled={removeBusyId === m.user_id}
331
+ onClick={() => removeMember(m.user_id)}
332
+ title="Remove from workspace"
333
+ >
334
+ {removeBusyId === m.user_id ? (
335
+ <Loader2 className="h-4 w-4 animate-spin" />
336
+ ) : (
337
+ <Trash2 className="h-4 w-4" />
338
+ )}
339
+ </Button>
340
+ )}
341
+ </td>
342
+ </tr>
343
+ );
344
+ })}
345
+ </tbody>
346
+ </table>
347
+ </div>
348
+ )}
349
+ </section>
350
+ </div>
351
+ </AppShell>
352
+ );
353
+ }