feat: enhance notifications system with UX improvements, new settings management links, and audit log for notification preferences
This commit is contained in:
@@ -8,7 +8,7 @@ import { useEffect, useState, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||
import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui'
|
||||
import { AlertTriangleIcon, CheckCircleIcon, SettingsIcon } from '@ciphera-net/ui'
|
||||
|
||||
// * Bell icon (simple SVG, no extra deps)
|
||||
function BellIcon({ className }: { className?: string }) {
|
||||
@@ -53,6 +53,7 @@ function getTypeIcon(type: string) {
|
||||
}
|
||||
|
||||
const LOADING_DELAY_MS = 250
|
||||
const POLL_INTERVAL_MS = 90_000
|
||||
|
||||
export default function NotificationCenter() {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -62,11 +63,20 @@ export default function NotificationCenter() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
try {
|
||||
const res = await listNotifications({ limit: 1 })
|
||||
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setError(null)
|
||||
const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY_MS)
|
||||
try {
|
||||
const res = await listNotifications()
|
||||
const res = await listNotifications({})
|
||||
setNotifications(Array.isArray(res?.notifications) ? res.notifications : [])
|
||||
setUnreadCount(typeof res?.unread_count === 'number' ? res.unread_count : 0)
|
||||
} catch (err) {
|
||||
@@ -85,6 +95,13 @@ export default function NotificationCenter() {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// * Poll unread count in background (when authenticated)
|
||||
useEffect(() => {
|
||||
fetchUnreadCount()
|
||||
const id = setInterval(fetchUnreadCount, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
// * Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
@@ -164,8 +181,14 @@ export default function NotificationCenter() {
|
||||
<div className="p-6 text-center text-red-500 text-sm">{error}</div>
|
||||
)}
|
||||
{!loading && !error && (notifications?.length ?? 0) === 0 && (
|
||||
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm">
|
||||
No notifications yet
|
||||
<div className="p-6 text-center text-neutral-500 dark:text-neutral-400 text-sm space-y-2">
|
||||
<p>No notifications yet</p>
|
||||
<p className="text-xs">
|
||||
Manage which notifications you receive in{' '}
|
||||
<Link href="/org-settings?tab=notifications" className="text-brand-orange hover:underline" onClick={() => setOpen(false)}>
|
||||
Organization Settings → Notifications
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (notifications?.length ?? 0) > 0 && (
|
||||
@@ -226,6 +249,24 @@ export default function NotificationCenter() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 px-4 py-3 flex items-center justify-between gap-2">
|
||||
<Link
|
||||
href="/notifications"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-brand-orange hover:underline"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
<Link
|
||||
href="/org-settings?tab=notifications"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-1.5 text-sm text-neutral-500 dark:text-neutral-400 hover:text-brand-orange dark:hover:text-brand-orange transition-colors"
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
Manage settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -286,6 +286,13 @@ export default function OrganizationSettings() {
|
||||
}
|
||||
}, [activeTab, currentOrgId, loadNotificationSettings])
|
||||
|
||||
// * Redirect members away from Notifications tab (owners/admins only)
|
||||
useEffect(() => {
|
||||
if (activeTab === 'notifications' && user?.role === 'member') {
|
||||
handleTabChange('general')
|
||||
}
|
||||
}, [activeTab, user?.role])
|
||||
|
||||
// If no org ID, we are in personal organization context, so don't show org settings
|
||||
if (!currentOrgId) {
|
||||
return (
|
||||
@@ -498,19 +505,21 @@ export default function OrganizationSettings() {
|
||||
<BoxIcon className="w-5 h-5" />
|
||||
Billing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('notifications')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'notifications'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<BellIcon className="w-5 h-5" />
|
||||
Notifications
|
||||
</button>
|
||||
{(user?.role === 'owner' || user?.role === 'admin') && (
|
||||
<button
|
||||
onClick={() => handleTabChange('notifications')}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'notifications'}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2 ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<BellIcon className="w-5 h-5" />
|
||||
Notifications
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleTabChange('audit')}
|
||||
role="tab"
|
||||
|
||||
Reference in New Issue
Block a user