BrandLogo gradient, EmptyStateIllustrations SVGs, categoryColors, landing page, brand SVG assets, and all remaining files. Warning #eab308 → #fbbf24 (amber). categoryColors deduped. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
Bell,
|
|
Mail,
|
|
Hash,
|
|
MessageSquare,
|
|
Plus,
|
|
Trash2,
|
|
Loader2,
|
|
Send,
|
|
ToggleLeft,
|
|
ToggleRight,
|
|
ChevronDown,
|
|
} from 'lucide-react'
|
|
import { notificationsApi } from '@/api/notifications'
|
|
import type { NotificationConfig, NotificationConfigCreate, NotificationConfigUpdate } from '@/types/notification'
|
|
import { NOTIFICATION_EVENTS, CHANNEL_LABELS } from '@/types/notification'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
import { Input } from '@/components/ui/Input'
|
|
|
|
type ChannelType = 'email' | 'slack_webhook' | 'teams_webhook'
|
|
|
|
const CHANNEL_ICONS: Record<ChannelType, React.ElementType> = {
|
|
email: Mail,
|
|
slack_webhook: Hash,
|
|
teams_webhook: MessageSquare,
|
|
}
|
|
|
|
function maskWebhookUrl(url: string): string {
|
|
if (url.length <= 8) return url
|
|
return '\u2022'.repeat(12) + url.slice(-8)
|
|
}
|
|
|
|
export function NotificationSettings() {
|
|
const [configs, setConfigs] = useState<NotificationConfig[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [addingChannel, setAddingChannel] = useState<ChannelType | null>(null)
|
|
const [testingId, setTestingId] = useState<string | null>(null)
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Add form state
|
|
const [newWebhookUrl, setNewWebhookUrl] = useState('')
|
|
const [newEmails, setNewEmails] = useState('')
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
loadConfigs()
|
|
}, [])
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
setShowDropdown(false)
|
|
}
|
|
}
|
|
if (showDropdown) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}, [showDropdown])
|
|
|
|
const loadConfigs = async () => {
|
|
try {
|
|
const data = await notificationsApi.listConfigs()
|
|
setConfigs(data)
|
|
} catch (err) {
|
|
console.error('Failed to load notification configs:', err)
|
|
toast.error('Failed to load notification settings')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleAddChannel = (channel: ChannelType) => {
|
|
setAddingChannel(channel)
|
|
setShowDropdown(false)
|
|
setNewWebhookUrl('')
|
|
setNewEmails('')
|
|
}
|
|
|
|
const handleSaveNew = async () => {
|
|
if (!addingChannel) return
|
|
setIsSaving(true)
|
|
try {
|
|
const payload: NotificationConfigCreate = { channel: addingChannel }
|
|
if (addingChannel === 'email') {
|
|
const emails = newEmails.split(',').map(e => e.trim()).filter(Boolean)
|
|
if (emails.length === 0) {
|
|
toast.error('Please enter at least one email address')
|
|
setIsSaving(false)
|
|
return
|
|
}
|
|
payload.email_addresses = emails
|
|
} else {
|
|
if (!newWebhookUrl.trim()) {
|
|
toast.error('Please enter a webhook URL')
|
|
setIsSaving(false)
|
|
return
|
|
}
|
|
payload.webhook_url = newWebhookUrl.trim()
|
|
}
|
|
await notificationsApi.createConfig(payload)
|
|
await loadConfigs()
|
|
setAddingChannel(null)
|
|
setNewWebhookUrl('')
|
|
setNewEmails('')
|
|
toast.success(`${CHANNEL_LABELS[addingChannel]} channel added`)
|
|
} catch (err) {
|
|
console.error('Failed to create notification config:', err)
|
|
toast.error('Failed to add channel')
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (config: NotificationConfig) => {
|
|
try {
|
|
const update: NotificationConfigUpdate = { is_active: !config.is_active }
|
|
await notificationsApi.updateConfig(config.id, update)
|
|
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, is_active: !c.is_active } : c))
|
|
toast.success(config.is_active ? 'Channel disabled' : 'Channel enabled')
|
|
} catch (err) {
|
|
console.error('Failed to toggle config:', err)
|
|
toast.error('Failed to update channel')
|
|
}
|
|
}
|
|
|
|
const handleToggleEvent = async (config: NotificationConfig, eventKey: string) => {
|
|
const updated = { ...config.events_enabled, [eventKey]: !config.events_enabled[eventKey] }
|
|
try {
|
|
await notificationsApi.updateConfig(config.id, { events_enabled: updated })
|
|
setConfigs(prev => prev.map(c => c.id === config.id ? { ...c, events_enabled: updated } : c))
|
|
} catch (err) {
|
|
console.error('Failed to update events:', err)
|
|
toast.error('Failed to update event settings')
|
|
}
|
|
}
|
|
|
|
const handleTest = async (configId: string) => {
|
|
setTestingId(configId)
|
|
try {
|
|
const result = await notificationsApi.testConfig(configId)
|
|
if (result.success) {
|
|
toast.success(result.message || 'Test notification sent')
|
|
} else {
|
|
toast.error(result.message || 'Test failed')
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to test config:', err)
|
|
toast.error('Test notification failed')
|
|
} finally {
|
|
setTestingId(null)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (configId: string) => {
|
|
try {
|
|
await notificationsApi.deleteConfig(configId)
|
|
setConfigs(prev => prev.filter(c => c.id !== configId))
|
|
toast.success('Channel removed')
|
|
} catch (err) {
|
|
console.error('Failed to delete config:', err)
|
|
toast.error('Failed to remove channel')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Section header */}
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Bell className="h-6 w-6 text-muted-foreground" />
|
|
<h2 className="text-xl font-semibold font-heading text-foreground">Notifications</h2>
|
|
</div>
|
|
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setShowDropdown(!showDropdown)}
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
|
|
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
|
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
|
)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Channel
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
|
|
{showDropdown && (
|
|
<div className="absolute right-0 mt-1 z-20 w-48 rounded-xl border border-border bg-card shadow-xl">
|
|
{(Object.entries(CHANNEL_LABELS) as [ChannelType, string][]).map(([key, label]) => {
|
|
const Icon = CHANNEL_ICONS[key]
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => handleAddChannel(key)}
|
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-[rgba(255,255,255,0.04)] first:rounded-t-xl last:rounded-b-xl transition-colors"
|
|
>
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{loading && (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!loading && configs.length === 0 && !addingChannel && (
|
|
<div className="card-flat p-6 text-center">
|
|
<Bell className="mx-auto h-8 w-8 text-muted-foreground/50 mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
No notification channels configured. Add a channel to receive alerts for session events.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Channel list */}
|
|
{!loading && (
|
|
<div className="space-y-4">
|
|
{configs.map(config => {
|
|
const Icon = CHANNEL_ICONS[config.channel]
|
|
return (
|
|
<div key={config.id} className="card-flat p-5">
|
|
{/* Header row */}
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">
|
|
{CHANNEL_LABELS[config.channel]}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'inline-flex h-2 w-2 rounded-full',
|
|
config.is_active ? 'bg-emerald-400' : 'bg-muted-foreground'
|
|
)}
|
|
/>
|
|
<span className={cn(
|
|
'text-xs font-sans text-xs',
|
|
config.is_active ? 'text-emerald-400' : 'text-muted-foreground'
|
|
)}>
|
|
{config.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Config details */}
|
|
<div className="mb-4">
|
|
{config.webhook_url && (
|
|
<div>
|
|
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Webhook URL
|
|
</span>
|
|
<p className="mt-0.5 text-sm text-foreground font-mono">
|
|
{maskWebhookUrl(config.webhook_url)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{config.email_addresses && config.email_addresses.length > 0 && (
|
|
<div>
|
|
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Email Addresses
|
|
</span>
|
|
<p className="mt-0.5 text-sm text-foreground">
|
|
{config.email_addresses.join(', ')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Event toggles */}
|
|
<div className="mb-4">
|
|
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Events
|
|
</span>
|
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
|
{Object.entries(NOTIFICATION_EVENTS).map(([eventKey, eventLabel]) => (
|
|
<label
|
|
key={eventKey}
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={config.events_enabled[eventKey] ?? false}
|
|
onChange={() => handleToggleEvent(config, eventKey)}
|
|
className="h-3.5 w-3.5 rounded border-border bg-card text-primary focus:ring-primary/30 focus:ring-offset-0 cursor-pointer accent-[#3b82f6]"
|
|
/>
|
|
<span className="text-sm text-foreground">{eventLabel}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
|
<button
|
|
onClick={() => handleToggleActive(config)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
|
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
|
'hover:border-[rgba(255,255,255,0.12)] transition-all'
|
|
)}
|
|
>
|
|
{config.is_active ? (
|
|
<ToggleRight className="h-4 w-4 text-emerald-400" />
|
|
) : (
|
|
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
{config.is_active ? 'Disable' : 'Enable'}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleTest(config.id)}
|
|
disabled={testingId === config.id}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
|
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
|
'hover:border-[rgba(255,255,255,0.12)] transition-all',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
{testingId === config.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
Test
|
|
</button>
|
|
|
|
{confirmDeleteId === config.id ? (
|
|
<button
|
|
onClick={() => { handleDelete(config.id); setConfirmDeleteId(null) }}
|
|
className="ml-auto inline-flex items-center gap-1.5 text-sm text-red-400 hover:text-red-300 transition-colors font-medium"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Confirm Remove
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setConfirmDeleteId(config.id)}
|
|
className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Inline add form */}
|
|
{addingChannel && (
|
|
<div className="card-flat p-5">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
{(() => {
|
|
const Icon = CHANNEL_ICONS[addingChannel]
|
|
return <Icon className="h-5 w-5 text-muted-foreground" />
|
|
})()}
|
|
<span className="text-sm font-medium text-foreground">
|
|
Add {CHANNEL_LABELS[addingChannel]}
|
|
</span>
|
|
</div>
|
|
|
|
{addingChannel === 'email' ? (
|
|
<div>
|
|
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Email Addresses
|
|
</label>
|
|
<Input
|
|
type="text"
|
|
value={newEmails}
|
|
onChange={(e) => setNewEmails(e.target.value)}
|
|
placeholder="user@example.com, team@example.com"
|
|
className="mt-1"
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Separate multiple addresses with commas
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
Webhook URL
|
|
</label>
|
|
<Input
|
|
type="url"
|
|
value={newWebhookUrl}
|
|
onChange={(e) => setNewWebhookUrl(e.target.value)}
|
|
placeholder={
|
|
addingChannel === 'slack_webhook'
|
|
? 'https://hooks.slack.com/services/...'
|
|
: 'https://outlook.office.com/webhook/...'
|
|
}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mt-4">
|
|
<button
|
|
onClick={handleSaveNew}
|
|
disabled={isSaving}
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-semibold',
|
|
'bg-primary text-white',
|
|
'hover:brightness-110 active:scale-[0.98] transition-all',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => setAddingChannel(null)}
|
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default NotificationSettings
|