diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 8376cc5..56ead05 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -70,10 +70,21 @@ def _target_username_col(target_alias, audit_model): COALESCE: prefer the live username from the users table, fall back to the username stored in the audit detail JSON (survives user deletion since audit_log.target_user_id → SET NULL). + Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values. """ + json_fallback = sa.case( + ( + sa.and_( + audit_model.detail.is_not(None), + audit_model.detail.startswith("{"), + ), + sa.cast(audit_model.detail, JSONB)["username"].as_string(), + ), + else_=sa.null(), + ) return sa.func.coalesce( target_alias.username, - sa.cast(audit_model.detail, JSONB)["username"].as_string(), + json_fallback, ).label("target_username") @@ -170,9 +181,9 @@ async def get_user( ) active_sessions = session_result.scalar_one() - # Fetch preferred_name from Settings + # Fetch preferred_name from Settings (limit 1 defensive) settings_result = await db.execute( - sa.select(Settings.preferred_name).where(Settings.user_id == user_id) + sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1) ) preferred_name = settings_result.scalar_one_or_none() @@ -181,6 +192,8 @@ async def get_user( active_sessions=active_sessions, preferred_name=preferred_name, date_of_birth=user.date_of_birth, + must_change_password=user.must_change_password, + locked_until=user.locked_until, ) @@ -242,6 +255,10 @@ async def create_user( return UserDetailResponse( **UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}), active_sessions=0, + preferred_name=data.preferred_name, + date_of_birth=None, + must_change_password=new_user.must_change_password, + locked_until=new_user.locked_until, ) diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx index afd00be..f699e96 100644 --- a/frontend/src/components/admin/ConfigPage.tsx +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -30,6 +30,10 @@ const ACTION_TYPES = [ 'auth.setup_complete', 'auth.registration', 'auth.mfa_enforce_prompted', + 'connection.request_sent', + 'connection.accepted', + 'connection.rejected', + 'connection.removed', ]; function actionLabel(action: string): string { @@ -44,7 +48,7 @@ export default function ConfigPage() { const [filterAction, setFilterAction] = useState(''); const PER_PAGE = 25; - const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined); + const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined); const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1; @@ -111,6 +115,11 @@ export default function ConfigPage() { ))} + ) : error ? ( +
+

Failed to load audit log

+

{error.message}

+
) : !data?.entries?.length ? (

No audit entries found.

) : ( diff --git a/frontend/src/components/admin/UserDetailSection.tsx b/frontend/src/components/admin/UserDetailSection.tsx index 83bdc7a..51261a4 100644 --- a/frontend/src/components/admin/UserDetailSection.tsx +++ b/frontend/src/components/admin/UserDetailSection.tsx @@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) } export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) { - const { data: user, isLoading } = useAdminUserDetail(userId); + const { data: user, isLoading, error } = useAdminUserDetail(userId); const updateRole = useUpdateRole(); const handleRoleChange = async (newRole: UserRole) => { @@ -89,6 +89,22 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection ); } + if (error) { + return ( + + +
+

Failed to load user details

+ +
+

{error.message}

+
+
+ ); + } + if (!user) return null; return ( diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 22a173a..55ee07c 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -7,6 +7,7 @@ import { LockProvider } from '@/hooks/useLock'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; import LockOverlay from './LockOverlay'; +import NotificationToaster from '@/components/notifications/NotificationToaster'; export default function AppLayout() { useTheme(); @@ -44,6 +45,7 @@ export default function AppLayout() { + ); diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx new file mode 100644 index 0000000..ea69f3e --- /dev/null +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { Check, X, Bell, UserPlus } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNotifications } from '@/hooks/useNotifications'; +import api from '@/lib/api'; +import type { AppNotification } from '@/types'; + +export default function NotificationToaster() { + const { notifications } = useNotifications(); + const queryClient = useQueryClient(); + const seenIdsRef = useRef(new Set()); + const initializedRef = useRef(false); + + const handleConnectionRespond = useCallback( + async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { + try { + await api.put(`/connections/requests/${requestId}/respond`, { action }); + toast.dismiss(toastId); + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + } catch { + toast.dismiss(toastId); + toast.error('Failed to respond to request'); + } + }, + [queryClient], + ); + + useEffect(() => { + if (!notifications.length && !initializedRef.current) return; + + // On first load, record all existing IDs without toasting + if (!initializedRef.current) { + notifications.forEach((n) => seenIdsRef.current.add(n.id)); + initializedRef.current = true; + return; + } + + // Find new notifications we haven't seen + const newNotifications = notifications.filter( + (n) => !n.is_read && !seenIdsRef.current.has(n.id), + ); + + // Record all current IDs + notifications.forEach((n) => seenIdsRef.current.add(n.id)); + + // Show toasts for new notifications + newNotifications.forEach((notification) => { + if (notification.type === 'connection_request' && notification.source_id) { + showConnectionRequestToast(notification); + } else { + toast(notification.title || 'New Notification', { + description: notification.message || undefined, + icon: , + duration: 8000, + }); + } + }); + }, [notifications, handleConnectionRespond]); + + const showConnectionRequestToast = (notification: AppNotification) => { + const requestId = notification.source_id!; + const senderName = + (notification.data as Record)?.sender_umbral_name || 'Someone'; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Connection Request

+

+ {notification.message || `${senderName} wants to connect with you`} +

+
+ + +
+
+
+
+ ), + { duration: 30000 }, + ); + }; + + return null; +} diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 177e78d..35533b2 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,10 +1,13 @@ import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; +import { useConnections } from '@/hooks/useConnections'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { getErrorMessage } from '@/lib/api'; import { ListSkeleton } from '@/components/ui/skeleton'; import type { AppNotification } from '@/types'; @@ -27,9 +30,16 @@ export default function NotificationsPage() { deleteNotification, } = useNotifications(); + const { incomingRequests, respond, isResponding } = useConnections(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); + // Build a set of pending connection request IDs for quick lookup + const pendingRequestIds = useMemo( + () => new Set(incomingRequests.map((r) => r.id)), + [incomingRequests], + ); + const filtered = useMemo(() => { if (filter === 'unread') return notifications.filter((n) => !n.is_read); return notifications; @@ -58,7 +68,31 @@ export default function NotificationsPage() { return config; }; + const handleConnectionRespond = async ( + notification: AppNotification, + action: 'accept' | 'reject', + ) => { + if (!notification.source_id) return; + try { + await respond({ requestId: notification.source_id, action }); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + }; + const handleNotificationClick = async (notification: AppNotification) => { + // Don't navigate for pending connection requests — let user act inline + if ( + notification.type === 'connection_request' && + notification.source_id && + pendingRequestIds.has(notification.source_id) + ) { + return; + } if (!notification.is_read) { await markRead([notification.id]).catch(() => {}); } @@ -168,6 +202,32 @@ export default function NotificationsPage() { + {/* Connection request actions (inline) */} + {notification.type === 'connection_request' && + notification.source_id && + pendingRequestIds.has(notification.source_id) && ( +
+ + +
+ )} + {/* Timestamp + actions */}
diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index cfd9347..4097bb9 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -6,6 +6,7 @@ import type { NotificationListResponse } from '@/types'; export function useNotifications() { const queryClient = useQueryClient(); const visibleRef = useRef(true); + const prevUnreadRef = useRef(undefined); // Track tab visibility to pause polling when hidden useEffect(() => { @@ -22,10 +23,20 @@ export function useNotifications() { const { data } = await api.get<{ count: number }>('/notifications/unread-count'); return data.count; }, - refetchInterval: () => (visibleRef.current ? 60_000 : false), - staleTime: 30_000, + refetchInterval: () => (visibleRef.current ? 30_000 : false), + staleTime: 15_000, }); + // When unread count increases, immediately refetch the notification list + useEffect(() => { + const count = unreadQuery.data; + if (count === undefined) return; + if (prevUnreadRef.current !== undefined && count > prevUnreadRef.current) { + queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] }); + } + prevUnreadRef.current = count; + }, [unreadQuery.data, queryClient]); + const listQuery = useQuery({ queryKey: ['notifications', 'list'], queryFn: async () => { @@ -34,7 +45,8 @@ export function useNotifications() { }); return data; }, - staleTime: 30_000, + staleTime: 15_000, + refetchInterval: () => (visibleRef.current ? 60_000 : false), }); const markReadMutation = useMutation({