Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 134 additions & 2 deletions app/(dashboard)/lists/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'

interface ListInfo {
Expand Down Expand Up @@ -81,8 +87,64 @@ export default function ListDetailPage() {
const [addFirstName, setAddFirstName] = useState('')
const [addLastName, setAddLastName] = useState('')
const [addSaving, setAddSaving] = useState(false)

const [gdprDeleteContact, setGdprDeleteContact] = useState<Contact | null>(null)
const [gdprConfirmEmail, setGdprConfirmEmail] = useState('')
const [gdprDeleting, setGdprDeleting] = useState(false)

const { toast } = useToast()

async function handleGdprExport(contact: Contact) {
try {
const res = await fetch(`/api/internal/contacts/${contact.id}/gdpr-export`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast({ title: data.error || 'Export failed', variant: 'destructive' })
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `contact-${contact.id}-export.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast({ title: 'Export downloaded' })
} catch {
toast({ title: 'Export failed', variant: 'destructive' })
}
}

async function handleGdprDelete() {
if (!gdprDeleteContact) return
if (gdprConfirmEmail.trim().toLowerCase() !== gdprDeleteContact.email.toLowerCase()) {
toast({ title: 'Email confirmation does not match', variant: 'destructive' })
return
}
setGdprDeleting(true)
try {
const url = `/api/internal/contacts/${gdprDeleteContact.id}/gdpr-delete?confirm=${encodeURIComponent(gdprDeleteContact.email)}`
const res = await fetch(url, { method: 'DELETE' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast({ title: data.error || 'Delete failed', variant: 'destructive' })
return
}
toast({ title: 'Contact and all related data deleted' })
setGdprDeleteContact(null)
setGdprConfirmEmail('')
fetchContacts()
const listRes = await fetch(`/api/internal/lists/${listId}`)
if (listRes.ok) setListInfo(await listRes.json())
} catch {
toast({ title: 'Delete failed', variant: 'destructive' })
} finally {
setGdprDeleting(false)
}
}

async function handleAddContact(e: React.FormEvent) {
e.preventDefault()
if (!addEmail.trim()) return
Expand Down Expand Up @@ -365,18 +427,19 @@ export default function ListDetailPage() {
<TableHead>First Name</TableHead>
<TableHead>Last Name</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{contactsLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground py-8">
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-8">
Loading contacts...
</TableCell>
</TableRow>
) : contacts.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-sm text-muted-foreground py-8">
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground py-8">
{search
? 'No contacts match your search.'
: `No ${tab} contacts in this list.`}
Expand All @@ -391,6 +454,30 @@ export default function ListDetailPage() {
<TableCell className="text-muted-foreground text-sm">
{format(new Date(contact.createdAt), 'MMM d, yyyy')}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2">
<span aria-hidden>...</span>
<span className="sr-only">Open contact actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleGdprExport(contact)}>
Export GDPR data
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => {
setGdprDeleteContact(contact)
setGdprConfirmEmail('')
}}
>
Delete (GDPR)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
Expand Down Expand Up @@ -430,6 +517,51 @@ export default function ListDetailPage() {
</TabsContent>
))}
</Tabs>

<Dialog
open={gdprDeleteContact !== null}
onOpenChange={(open) => {
if (!open) {
setGdprDeleteContact(null)
setGdprConfirmEmail('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Hard delete contact (GDPR)</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>
This will permanently delete <span className="font-mono">{gdprDeleteContact?.email}</span> and all associated send and engagement records. This cannot be undone.
</p>
<p className="text-muted-foreground">
Type the email below to confirm.
</p>
<Input
placeholder={gdprDeleteContact?.email ?? ''}
value={gdprConfirmEmail}
onChange={(e) => setGdprConfirmEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGdprDeleteContact(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={
gdprDeleting ||
!gdprDeleteContact ||
gdprConfirmEmail.trim().toLowerCase() !== gdprDeleteContact.email.toLowerCase()
}
onClick={handleGdprDelete}
>
{gdprDeleting ? 'Deleting...' : 'Delete permanently'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
81 changes: 80 additions & 1 deletion app/(dashboard)/settings/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ApiKey {
id: string
name: string
lastUsedAt: string | null
rateLimitPerMinute: number
createdAt: string
}

Expand All @@ -39,6 +40,11 @@ export default function ApiKeysPage() {
const [name, setName] = useState("")
const [saving, setSaving] = useState(false)
const [newKey, setNewKey] = useState<string | null>(null)

const [editingId, setEditingId] = useState<string | null>(null)
const [editLimit, setEditLimit] = useState<string>("")
const [editSaving, setEditSaving] = useState(false)

const { toast } = useToast()

const fetchKeys = async () => {
Expand Down Expand Up @@ -94,6 +100,35 @@ export default function ApiKeysPage() {
}
}

const handleSaveLimit = async () => {
if (!editingId) return
const value = parseInt(editLimit, 10)
if (Number.isNaN(value) || value < 1) {
toast({ title: "Enter a number greater than 0", variant: "destructive" })
return
}
setEditSaving(true)
try {
const res = await fetch(`/api/internal/api-keys/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rateLimitPerMinute: value }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
toast({ title: data.error || "Failed to update rate limit", variant: "destructive" })
return
}
toast({ title: "Rate limit updated" })
setEditingId(null)
fetchKeys()
} catch {
toast({ title: "Failed to update rate limit", variant: "destructive" })
} finally {
setEditSaving(false)
}
}

const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
toast({ title: "Copied to clipboard" })
Expand Down Expand Up @@ -192,15 +227,28 @@ export default function ApiKeysPage() {
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Requests / min</TableHead>
<TableHead>Last Used</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-20"></TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(key.id)
setEditLimit(String(key.rateLimitPerMinute))
}}
>
{key.rateLimitPerMinute} / min
</Button>
</TableCell>
<TableCell>
{key.lastUsedAt ? format(new Date(key.lastUsedAt), "MMM d, yyyy HH:mm") : "Never"}
</TableCell>
Expand All @@ -221,6 +269,37 @@ export default function ApiKeysPage() {
</Table>
)}

{/* Edit rate limit dialog */}
<Dialog open={editingId !== null} onOpenChange={(open) => { if (!open) setEditingId(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Rate Limit</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="edit-limit">Requests per minute</Label>
<Input
id="edit-limit"
type="number"
min={1}
max={100000}
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
Token bucket capacity, refills at this rate per minute. The bucket is reset to full when you save.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingId(null)}>Cancel</Button>
<Button onClick={handleSaveLimit} disabled={editSaving}>
{editSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Delete confirmation dialog */}
<Dialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null) }}>
<DialogContent>
Expand Down
Loading
Loading