| import React, { useCallback, useEffect, useMemo, useState } from 'react'; |
| import { |
| ArrowLeft, |
| ClipboardCopy, |
| Loader2, |
| ShieldAlert, |
| Link2, |
| Sparkles, |
| UserPlus, |
| Users, |
| Trash2, |
| } from 'lucide-react'; |
| import AppShell from '@/components/layout/AppShell'; |
| import EmailGeneratorTab from '@/components/campaigns/EmailGeneratorTab'; |
| import LinkedInConnectSettings from '@/components/settings/LinkedInConnectSettings'; |
| import ConnectMailboxSettings from '@/components/settings/ConnectMailboxSettings'; |
| import { Button } from '@/components/ui/button'; |
| import { Input } from '@/components/ui/input'; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from '@/components/ui/select'; |
| import { apiFetch } from '@/lib/api'; |
| import { cn } from '@/lib/utils'; |
|
|
| export default function Settings() { |
| |
| const [settingsPanel, setSettingsPanel] = useState('main'); |
| const [me, setMe] = useState(null); |
| const [meLoading, setMeLoading] = useState(true); |
| const [members, setMembers] = useState([]); |
| const [membersLoading, setMembersLoading] = useState(true); |
| const [membersError, setMembersError] = useState(''); |
|
|
| const [inviteEmail, setInviteEmail] = useState(''); |
| const [inviteBusy, setInviteBusy] = useState(false); |
| const [inviteResult, setInviteResult] = useState(null); |
|
|
| |
| const [removeBusyKey, setRemoveBusyKey] = useState(null); |
|
|
| const loadMe = useCallback(async () => { |
| setMeLoading(true); |
| try { |
| const r = await apiFetch('/api/auth/me'); |
| setMe(r.ok ? await r.json() : null); |
| } catch { |
| setMe(null); |
| } finally { |
| setMeLoading(false); |
| } |
| }, []); |
|
|
| const loadMembers = useCallback(async () => { |
| setMembersLoading(true); |
| setMembersError(''); |
| try { |
| const r = await apiFetch('/api/tenants/members'); |
| if (!r.ok) { |
| const data = await r.json().catch(() => ({})); |
| setMembersError( |
| typeof data.detail === 'string' ? data.detail : 'Could not load members' |
| ); |
| setMembers([]); |
| return; |
| } |
| const data = await r.json(); |
| setMembers(data.members || []); |
| } catch (e) { |
| setMembersError(String(e)); |
| setMembers([]); |
| } finally { |
| setMembersLoading(false); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| loadMe(); |
| }, [loadMe]); |
|
|
| const isAdmin = me?.current_role === 'admin'; |
|
|
| useEffect(() => { |
| if (!meLoading && isAdmin) { |
| loadMembers(); |
| } |
| }, [meLoading, isAdmin, loadMembers]); |
|
|
| const webhookUrl = useMemo(() => { |
| if (typeof window === 'undefined') return ''; |
| const base = `${window.location.origin}/api/webhooks/smartlead`; |
| const tid = me?.current_tenant_id; |
| if (tid != null && !Number.isNaN(Number(tid))) { |
| return `${base}?tenant_id=${tid}`; |
| } |
| return base; |
| }, [me]); |
|
|
| const tenants = me?.tenants || []; |
| const multiWorkspace = tenants.length > 1; |
|
|
| const switchTenant = async (tid) => { |
| const id = parseInt(tid, 10); |
| if (!id || id === me?.current_tenant_id) return; |
| try { |
| const res = await apiFetch('/api/auth/switch-tenant', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ tenant_id: id }), |
| }); |
| if (!res.ok) return; |
| window.location.reload(); |
| } catch (e) { |
| console.error(e); |
| } |
| }; |
|
|
| const sendInvite = async () => { |
| const email = inviteEmail.trim().toLowerCase(); |
| if (!email || !email.includes('@')) return; |
| setInviteBusy(true); |
| setInviteResult(null); |
| try { |
| const res = await apiFetch('/api/tenants/invite', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ email, role: 'member' }), |
| }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| setInviteResult({ |
| error: typeof data.detail === 'string' ? data.detail : 'Invite failed', |
| }); |
| return; |
| } |
| setInviteResult({ |
| url: data.invite_url, |
| emailSent: !!data.email_sent, |
| emailError: data.email_error || null, |
| inviteeEmail: data.email, |
| }); |
| setInviteEmail(''); |
| loadMe(); |
| await loadMembers(); |
| } catch (e) { |
| setInviteResult({ error: String(e) }); |
| } finally { |
| setInviteBusy(false); |
| } |
| }; |
|
|
| const removeMember = async (userId) => { |
| if (!window.confirm('Remove this user from the workspace?')) return; |
| setRemoveBusyKey(`member:${userId}`); |
| try { |
| const res = await apiFetch(`/api/tenants/members/${userId}`, { method: 'DELETE' }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| alert(typeof data.detail === 'string' ? data.detail : 'Could not remove member'); |
| return; |
| } |
| await loadMembers(); |
| } catch (e) { |
| alert(e.message || 'Could not remove member'); |
| } finally { |
| setRemoveBusyKey(null); |
| } |
| }; |
|
|
| const revokeInvitation = async (invitationId) => { |
| if (!window.confirm('Cancel this invitation? They will not be able to join with the current link.')) |
| return; |
| setRemoveBusyKey(`invite:${invitationId}`); |
| try { |
| const res = await apiFetch(`/api/tenants/invitations/${invitationId}`, { method: 'DELETE' }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) { |
| alert(typeof data.detail === 'string' ? data.detail : 'Could not cancel invitation'); |
| return; |
| } |
| await loadMembers(); |
| } catch (e) { |
| alert(e.message || 'Could not cancel invitation'); |
| } finally { |
| setRemoveBusyKey(null); |
| } |
| }; |
|
|
| if (meLoading) { |
| return ( |
| <AppShell title="Settings"> |
| <div className="flex justify-center py-24 text-slate-500"> |
| <Loader2 className="h-10 w-10 animate-spin" /> |
| </div> |
| </AppShell> |
| ); |
| } |
|
|
| if (!me) { |
| return ( |
| <AppShell title="Settings"> |
| <div className="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm"> |
| <p className="text-slate-600 mb-4">Sign in to manage workspace settings.</p> |
| <Button asChild variant="default"> |
| <a href="/api/auth/google">Sign in with Google</a> |
| </Button> |
| </div> |
| </AppShell> |
| ); |
| } |
|
|
| if (settingsPanel === 'email_generator') { |
| return ( |
| <AppShell |
| title="Settings" |
| subtitle="Email / AI generator — upload contacts, prompts, and export." |
| > |
| <div className="w-full max-w-6xl"> |
| <div className="mb-6 flex flex-wrap items-center gap-3"> |
| <Button |
| type="button" |
| variant="outline" |
| className="gap-2" |
| onClick={() => setSettingsPanel('main')} |
| > |
| <ArrowLeft className="h-4 w-4" aria-hidden /> |
| Back to settings |
| </Button> |
| </div> |
| <EmailGeneratorTab /> |
| </div> |
| </AppShell> |
| ); |
| } |
|
|
| return ( |
| <AppShell |
| title="Settings" |
| subtitle={ |
| isAdmin |
| ? 'Invitations, members, LinkedIn, mailbox, Smartlead webhook, and email generator.' |
| : 'LinkedIn, mailbox, Smartlead webhook, and email generator. Invites and member management require admin.' |
| } |
| > |
| <div className="space-y-8 max-w-3xl"> |
| {!isAdmin ? ( |
| <div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-5 flex gap-3 items-start"> |
| <ShieldAlert className="h-6 w-6 shrink-0 text-slate-500 mt-0.5" /> |
| <p className="text-sm text-slate-600"> |
| Inviting users and removing members is limited to workspace admins. Ask an |
| admin if you need help. |
| </p> |
| </div> |
| ) : null} |
| |
| {isAdmin ? ( |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> |
| <div className="flex items-center gap-2 text-slate-800 font-semibold mb-2"> |
| <UserPlus className="h-5 w-5 text-violet-600" /> |
| Invite people |
| </div> |
| <p className="text-sm text-slate-600 mb-4"> |
| We email an invitation from your Google account. The invitee must sign in with the |
| same email address. |
| </p> |
| {me?.gmail_invites_ready === false ? ( |
| <p className="text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4"> |
| Sign in with Google again (you will see a consent screen) so invites can send from your |
| address, or use{' '} |
| <a |
| href="/api/auth/google?reauth_gmail=1" |
| className="font-medium text-violet-700 underline" |
| > |
| Reconnect Google for invites |
| </a> |
| . |
| </p> |
| ) : null} |
| <div className="flex flex-col sm:flex-row gap-2 max-w-xl"> |
| <Input |
| type="email" |
| placeholder="colleague@company.com" |
| value={inviteEmail} |
| onChange={(e) => setInviteEmail(e.target.value)} |
| className="h-10" |
| /> |
| <Button |
| type="button" |
| disabled={inviteBusy} |
| onClick={sendInvite} |
| className="shrink-0" |
| > |
| {inviteBusy ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| 'Send invite' |
| )} |
| </Button> |
| </div> |
| {inviteResult?.error ? ( |
| <p className="text-sm text-red-600 mt-3">{inviteResult.error}</p> |
| ) : null} |
| {inviteResult?.emailSent ? ( |
| <p className="text-sm text-green-700 mt-3"> |
| Invitation email sent to {inviteResult.inviteeEmail}. |
| </p> |
| ) : null} |
| {inviteResult?.emailError ? ( |
| <p className="text-sm text-amber-800 mt-3">{inviteResult.emailError}</p> |
| ) : null} |
| {inviteResult?.url ? ( |
| <div className="mt-4 space-y-2"> |
| <p className="text-xs text-slate-600"> |
| Invite link (copy if email did not arrive; expires in 7 days): |
| </p> |
| <Input readOnly value={inviteResult.url} className="text-xs h-9 font-mono" /> |
| </div> |
| ) : null} |
| </section> |
| ) : null} |
| |
| {isAdmin ? ( |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> |
| <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4"> |
| <Users className="h-5 w-5 text-violet-600" /> |
| Members |
| </div> |
| {membersLoading ? ( |
| <div className="flex justify-center py-12 text-slate-400"> |
| <Loader2 className="h-8 w-8 animate-spin" /> |
| </div> |
| ) : membersError ? ( |
| <p className="text-sm text-red-600">{membersError}</p> |
| ) : members.length === 0 ? ( |
| <p className="text-sm text-slate-500">No members or pending invitations.</p> |
| ) : ( |
| <div className="overflow-x-auto rounded-xl border border-slate-100"> |
| <table className="w-full text-sm text-left"> |
| <thead> |
| <tr className="border-b border-slate-100 bg-slate-50/80"> |
| <th className="px-4 py-3 font-medium text-slate-600">Email</th> |
| <th className="px-4 py-3 font-medium text-slate-600">Name</th> |
| <th className="px-4 py-3 font-medium text-slate-600">Role</th> |
| <th className="px-4 py-3 font-medium text-slate-600">Status</th> |
| <th className="px-4 py-3 font-medium text-slate-600 w-28" /> |
| </tr> |
| </thead> |
| <tbody> |
| {members.map((m) => { |
| const rowKey = |
| m.user_id != null ? `m-${m.user_id}` : `i-${m.invitation_id}`; |
| const isSelf = m.user_id === me.user_id; |
| const isInvited = m.status === 'invited'; |
| const busyMember = removeBusyKey === `member:${m.user_id}`; |
| const busyInvite = |
| m.invitation_id != null && |
| removeBusyKey === `invite:${m.invitation_id}`; |
| return ( |
| <tr |
| key={rowKey} |
| className="border-b border-slate-50 last:border-0" |
| > |
| <td className="px-4 py-3 text-slate-900"> |
| {m.email || '—'} |
| </td> |
| <td className="px-4 py-3 text-slate-700"> |
| {m.name || '—'} |
| </td> |
| <td className="px-4 py-3"> |
| <span |
| className={cn( |
| 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', |
| m.role === 'admin' |
| ? 'bg-violet-100 text-violet-800' |
| : 'bg-slate-100 text-slate-700' |
| )} |
| > |
| {m.role} |
| </span> |
| </td> |
| <td className="px-4 py-3"> |
| <span |
| className={cn( |
| 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', |
| isInvited |
| ? 'bg-amber-50 text-amber-900 ring-1 ring-amber-200/80' |
| : 'bg-emerald-50 text-emerald-900 ring-1 ring-emerald-200/80' |
| )} |
| > |
| {isInvited ? 'Invited' : 'Active'} |
| </span> |
| </td> |
| <td className="px-4 py-3 text-right"> |
| {isSelf ? ( |
| <span className="text-xs text-slate-400"> |
| You |
| </span> |
| ) : isInvited ? ( |
| <Button |
| type="button" |
| variant="ghost" |
| size="sm" |
| className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50" |
| disabled={busyInvite} |
| onClick={() => revokeInvitation(m.invitation_id)} |
| title="Cancel invitation" |
| > |
| {busyInvite ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Trash2 className="h-4 w-4" /> |
| )} |
| </Button> |
| ) : ( |
| <Button |
| type="button" |
| variant="ghost" |
| size="sm" |
| className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50" |
| disabled={busyMember} |
| onClick={() => removeMember(m.user_id)} |
| title="Remove from workspace" |
| > |
| {busyMember ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Trash2 className="h-4 w-4" /> |
| )} |
| </Button> |
| )} |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </section> |
| ) : null} |
| |
| <LinkedInConnectSettings /> |
| <ConnectMailboxSettings /> |
| |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> |
| <div className="flex items-center gap-2 text-slate-800 font-semibold mb-2"> |
| <Sparkles className="h-5 w-5 text-violet-600" /> |
| Email / AI generator |
| </div> |
| <p className="text-sm text-slate-600 mb-4"> |
| Import an Apollo CSV, choose products, edit prompts, and generate email sequences in |
| a guided flow. Opens full width here so you can focus on the tool. |
| </p> |
| <Button type="button" onClick={() => setSettingsPanel('email_generator')}> |
| Open Email / AI generator |
| </Button> |
| </section> |
| |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"> |
| <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4"> |
| <Link2 className="h-5 w-5 text-violet-600" /> |
| Smartlead webhook |
| </div> |
| <p className="text-xs text-slate-500 mb-4"> |
| Paste this URL in Smartlead so reply events are stored for this workspace. The link |
| includes <span className="font-mono text-slate-700">tenant_id</span> for the active |
| workspace. |
| </p> |
| <div className="flex flex-wrap items-center gap-2 mb-6"> |
| <code className="text-xs bg-slate-100 px-2 py-1.5 rounded break-all flex-1 min-w-[12rem]"> |
| {webhookUrl} |
| </code> |
| <Button |
| type="button" |
| variant="outline" |
| size="sm" |
| className="shrink-0 gap-1" |
| onClick={() => webhookUrl && navigator.clipboard.writeText(webhookUrl)} |
| > |
| <ClipboardCopy className="h-3.5 w-3.5" /> |
| Copy |
| </Button> |
| </div> |
| {multiWorkspace ? ( |
| <div className="pt-4 border-t border-slate-100"> |
| <p className="text-xs font-medium text-slate-600 mb-2">Switch workspace</p> |
| <p className="text-xs text-slate-500 mb-2"> |
| The webhook URL updates when you change workspace (different{' '} |
| <span className="font-mono">tenant_id</span>). |
| </p> |
| <Select |
| value={String(me.current_tenant_id ?? '')} |
| onValueChange={switchTenant} |
| > |
| <SelectTrigger className="h-10 max-w-md border-slate-200 text-sm"> |
| <SelectValue placeholder="Choose workspace" /> |
| </SelectTrigger> |
| <SelectContent> |
| {tenants.map((tn) => ( |
| <SelectItem key={tn.id} value={String(tn.id)}> |
| {tn.name} |
| {tn.role === 'admin' ? ' · admin' : ''} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| ) : null} |
| </section> |
| </div> |
| </AppShell> |
| ); |
| } |
|
|