Files
resolutionflow/frontend/src/components/account/NotificationSettings.tsx
chihlasm 1152b023bf feat: replace hardcoded orange hex values with blue equivalents
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>
2026-03-29 16:20:52 +00:00

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