Users can now receive a unique email address (ask+<token>@domain) and interact with Simba by sending emails. Inbound emails hit a Mailgun webhook, are authenticated via HMAC token lookup, processed through the LangChain agent, and replied to via the Mailgun API. - Extract shared SIMBA_SYSTEM_PROMPT to blueprints/conversation/prompts.py - Add email_enabled and email_hmac_token fields to User model - Create blueprints/email with webhook, signature validation, rate limiting - Add admin endpoints to enable/disable email per user - Update AdminPanel with Email column, toggle, and copy-address button - Add Mailgun env vars to .env.example - Include database migration for new fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
|
import { userService, type AdminUserRecord } from "../api/userService";
|
|
import { cn } from "../lib/utils";
|
|
import { Button } from "./ui/button";
|
|
import { Input } from "./ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "./ui/table";
|
|
|
|
type Props = {
|
|
onClose: () => void;
|
|
};
|
|
|
|
export const AdminPanel = ({ onClose }: Props) => {
|
|
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editValue, setEditValue] = useState("");
|
|
const [rowError, setRowError] = useState<Record<string, string>>({});
|
|
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
userService
|
|
.adminListUsers()
|
|
.then(setUsers)
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const startEdit = (user: AdminUserRecord) => {
|
|
setEditingId(user.id);
|
|
setEditValue(user.whatsapp_number ?? "");
|
|
setRowError((p) => ({ ...p, [user.id]: "" }));
|
|
setRowSuccess((p) => ({ ...p, [user.id]: "" }));
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingId(null);
|
|
setEditValue("");
|
|
};
|
|
|
|
const saveWhatsapp = async (userId: string) => {
|
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
|
try {
|
|
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
|
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
|
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
|
|
setEditingId(null);
|
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
|
} catch (err) {
|
|
setRowError((p) => ({
|
|
...p,
|
|
[userId]: err instanceof Error ? err.message : "Failed to save",
|
|
}));
|
|
}
|
|
};
|
|
|
|
const unlinkWhatsapp = async (userId: string) => {
|
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
|
try {
|
|
await userService.adminUnlinkWhatsapp(userId);
|
|
setUsers((p) =>
|
|
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
|
);
|
|
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
|
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
|
} catch (err) {
|
|
setRowError((p) => ({
|
|
...p,
|
|
[userId]: err instanceof Error ? err.message : "Failed to unlink",
|
|
}));
|
|
}
|
|
};
|
|
|
|
const toggleEmail = async (userId: string) => {
|
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
|
try {
|
|
const updated = await userService.adminToggleEmail(userId);
|
|
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
|
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
|
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
|
} catch (err) {
|
|
setRowError((p) => ({
|
|
...p,
|
|
[userId]: err instanceof Error ? err.message : "Failed to enable email",
|
|
}));
|
|
}
|
|
};
|
|
|
|
const disableEmail = async (userId: string) => {
|
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
|
try {
|
|
await userService.adminDisableEmail(userId);
|
|
setUsers((p) =>
|
|
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
|
|
);
|
|
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
|
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
|
} catch (err) {
|
|
setRowError((p) => ({
|
|
...p,
|
|
[userId]: err instanceof Error ? err.message : "Failed to disable email",
|
|
}));
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (text: string, userId: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
|
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
|
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
|
|
"w-full max-w-3xl mx-4 max-h-[82vh] flex flex-col",
|
|
"border border-sand-light/60",
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
|
<Phone size={14} className="text-leaf-dark" />
|
|
</div>
|
|
<h2 className="text-sm font-semibold text-charcoal">
|
|
Admin · User Integrations
|
|
</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
|
{loading ? (
|
|
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
|
<div className="flex justify-center gap-1.5 mb-3">
|
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
|
</div>
|
|
Loading users…
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Username</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>WhatsApp</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead className="w-28">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{users.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-medium text-charcoal">
|
|
{user.username}
|
|
</TableCell>
|
|
<TableCell className="text-warm-gray">{user.email}</TableCell>
|
|
<TableCell>
|
|
{editingId === user.id ? (
|
|
<div className="flex flex-col gap-1">
|
|
<Input
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
placeholder="whatsapp:+15551234567"
|
|
className="w-52"
|
|
autoFocus
|
|
onKeyDown={(e) =>
|
|
e.key === "Enter" && saveWhatsapp(user.id)
|
|
}
|
|
/>
|
|
{rowError[user.id] && (
|
|
<span className="text-xs text-red-500">
|
|
{rowError[user.id]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span
|
|
className={cn(
|
|
"text-sm",
|
|
user.whatsapp_number
|
|
? "text-charcoal"
|
|
: "text-warm-gray/40 italic",
|
|
)}
|
|
>
|
|
{user.whatsapp_number ?? "—"}
|
|
</span>
|
|
{rowSuccess[user.id] && (
|
|
<span className="text-xs text-leaf-dark">
|
|
{rowSuccess[user.id]}
|
|
</span>
|
|
)}
|
|
{rowError[user.id] && (
|
|
<span className="text-xs text-red-500">
|
|
{rowError[user.id]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col gap-0.5">
|
|
{user.email_enabled && user.email_address ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm text-charcoal truncate max-w-[180px]" title={user.email_address}>
|
|
{user.email_address}
|
|
</span>
|
|
<button
|
|
onClick={() => copyToClipboard(user.email_address!, user.id)}
|
|
className="text-warm-gray hover:text-charcoal transition-colors cursor-pointer"
|
|
title="Copy address"
|
|
>
|
|
<Copy size={11} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-warm-gray/40 italic">—</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{editingId === user.id ? (
|
|
<div className="flex gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={() => saveWhatsapp(user.id)}
|
|
>
|
|
<Check size={12} />
|
|
Save
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost-dark"
|
|
onClick={cancelEdit}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-1.5">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost-dark"
|
|
onClick={() => startEdit(user)}
|
|
>
|
|
<Pencil size={11} />
|
|
Edit
|
|
</Button>
|
|
{user.whatsapp_number && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => unlinkWhatsapp(user.id)}
|
|
>
|
|
<PhoneOff size={11} />
|
|
Unlink
|
|
</Button>
|
|
)}
|
|
{user.email_enabled ? (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => disableEmail(user.id)}
|
|
>
|
|
<Mail size={11} />
|
|
Email
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost-dark"
|
|
onClick={() => toggleEmail(user.id)}
|
|
>
|
|
<Mail size={11} />
|
|
Email
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|