EMAILOUT / frontend /src /pages /Settings.jsx
Seth
update
1695b82
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() {
/** 'main' | 'email_generator' — full-page generator inside 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);
/** e.g. `member:12` or `invite:3` while a remove/revoke request is in flight */
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>
);
}