From 7053cf5d5e3abc433191e556b3156caf009b339f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 19:58:49 +0100 Subject: [PATCH] feat: add security activity and trusted devices management to settings page --- app/settings/SettingsPageClient.tsx | 60 ++++- components/settings/SecurityActivityCard.tsx | 246 +++++++++++++++++++ components/settings/TrustedDevicesCard.tsx | 160 ++++++++++++ lib/api/activity.ts | 28 +++ lib/api/devices.ts | 19 ++ 5 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 components/settings/SecurityActivityCard.tsx create mode 100644 components/settings/TrustedDevicesCard.tsx create mode 100644 lib/api/activity.ts create mode 100644 lib/api/devices.ts diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx index 6279c07..ee478c6 100644 --- a/app/settings/SettingsPageClient.tsx +++ b/app/settings/SettingsPageClient.tsx @@ -1,8 +1,11 @@ 'use client' import { useState } from 'react' +import Link from 'next/link' import { useAuth } from '@/lib/auth/context' import ProfileSettings from '@/components/settings/ProfileSettings' +import TrustedDevicesCard from '@/components/settings/TrustedDevicesCard' +import SecurityActivityCard from '@/components/settings/SecurityActivityCard' import { motion, AnimatePresence } from 'framer-motion' import { UserIcon, @@ -13,13 +16,26 @@ import { ExternalLinkIcon, } from '@ciphera-net/ui' +// Inline SVG icons not available in ciphera-ui +function BellIcon({ className }: { className?: string }) { + return ( + + + + + ) +} + // --- Types --- type ProfileSubTab = 'profile' | 'security' | 'preferences' type ActiveSelection = | { section: 'profile'; subTab: ProfileSubTab } + | { section: 'notifications' } | { section: 'account' } + | { section: 'devices' } + | { section: 'activity' } type ExpandableSection = 'profile' | 'account' @@ -217,8 +233,31 @@ function AppSettingsSection() { switch (active.section) { case 'profile': return + case 'notifications': + return ( +
+
+ +

Notification Preferences

+

+ Configure which notifications you receive and how you want to be notified. +

+ + Open Notification Center + + +
+
+ ) case 'account': return + case 'devices': + return + case 'activity': + return default: return null } @@ -266,6 +305,17 @@ function AppSettingsSection() { /> + + {/* Notifications (flat, no expansion) */} + setActive({ section: 'notifications' })} + icon={BellIcon} + label="Notifications" + description="Email and in-app notifications" + hasChildren={false} + /> @@ -308,16 +358,14 @@ function AppSettingsSection() { external /> window.open('https://auth.ciphera.net/devices', '_blank')} + active={active.section === 'devices'} + onClick={() => setActive({ section: 'devices' })} label="Trusted Devices" - external /> window.open('https://auth.ciphera.net/activity', '_blank')} + active={active.section === 'activity'} + onClick={() => setActive({ section: 'activity' })} label="Security Activity" - external /> diff --git a/components/settings/SecurityActivityCard.tsx b/components/settings/SecurityActivityCard.tsx new file mode 100644 index 0000000..47b1ba9 --- /dev/null +++ b/components/settings/SecurityActivityCard.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useAuth } from '@/lib/auth/context' +import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity' +import { Spinner } from '@ciphera-net/ui' + +const PAGE_SIZE = 20 + +const EVENT_LABELS: Record = { + login_success: 'Sign in', + login_failure: 'Failed sign in', + oauth_login_success: 'OAuth sign in', + oauth_login_failure: 'Failed OAuth sign in', + password_change: 'Password changed', + '2fa_enabled': '2FA enabled', + '2fa_disabled': '2FA disabled', + recovery_codes_regenerated: 'Recovery codes regenerated', + account_deleted: 'Account deleted', +} + +const EVENT_ICONS: Record = { + login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9', + login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z', + oauth_login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9', + oauth_login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z', + password_change: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z', + '2fa_enabled': 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z', + '2fa_disabled': 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z', + recovery_codes_regenerated: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z', + account_deleted: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0', +} + +function getEventColor(eventType: string, outcome: string): string { + if (outcome === 'failure') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30' + if (eventType === '2fa_enabled') return 'text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30' + if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30' + if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30' + if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30' + return 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800' +} + +function getMethodLabel(entry: AuditLogEntry): string | null { + const method = entry.metadata?.method + if (!method) return null + if (method === 'magic_link') return 'Magic link' + if (method === 'passkey') return 'Passkey' + return method as string +} + +function getFailureReason(entry: AuditLogEntry): string | null { + if (entry.outcome !== 'failure') return null + const reason = entry.metadata?.reason + if (!reason) return null + const labels: Record = { + invalid_credentials: 'Invalid credentials', + invalid_password: 'Wrong password', + account_locked: 'Account locked', + email_not_verified: 'Email not verified', + invalid_2fa: 'Invalid 2FA code', + } + return labels[reason as string] || (reason as string).replace(/_/g, ' ') +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + const diffHr = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHr / 24) + + if (diffMin < 1) return 'Just now' + if (diffMin < 60) return `${diffMin}m ago` + if (diffHr < 24) return `${diffHr}h ago` + if (diffDay < 7) return `${diffDay}d ago` + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }) +} + +function formatFullDate(dateStr: string): string { + return new Date(dateStr).toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function parseBrowserName(ua: string): string { + if (!ua) return 'Unknown' + if (ua.includes('Firefox')) return 'Firefox' + if (ua.includes('Edg/')) return 'Edge' + if (ua.includes('Chrome')) return 'Chrome' + if (ua.includes('Safari')) return 'Safari' + if (ua.includes('Opera') || ua.includes('OPR')) return 'Opera' + return 'Browser' +} + +function parseOS(ua: string): string { + if (!ua) return '' + if (ua.includes('Mac OS X')) return 'macOS' + if (ua.includes('Windows')) return 'Windows' + if (ua.includes('Linux')) return 'Linux' + if (ua.includes('Android')) return 'Android' + if (ua.includes('iPhone') || ua.includes('iPad')) return 'iOS' + return '' +} + +export default function SecurityActivityCard() { + const { user } = useAuth() + const [entries, setEntries] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState('') + const [offset, setOffset] = useState(0) + + const fetchActivity = useCallback(async (currentOffset: number, append: boolean) => { + try { + const data = await getUserActivity(PAGE_SIZE, currentOffset) + const newEntries = data.entries ?? [] + setEntries(prev => append ? [...prev, ...newEntries] : newEntries) + setTotalCount(data.total_count) + setHasMore(data.has_more) + setOffset(currentOffset + newEntries.length) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load activity') + } + }, []) + + useEffect(() => { + if (!user) return + setLoading(true) + fetchActivity(0, false).finally(() => setLoading(false)) + }, [user, fetchActivity]) + + const handleLoadMore = async () => { + setLoadingMore(true) + await fetchActivity(offset, true) + setLoadingMore(false) + } + + return ( +
+

Security Activity

+

+ Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''} +

+ + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : entries.length === 0 ? ( +
+ + + +

No activity recorded yet.

+
+ ) : ( +
+ {entries.map((entry) => { + const label = EVENT_LABELS[entry.event_type] || entry.event_type.replace(/_/g, ' ') + const color = getEventColor(entry.event_type, entry.outcome) + const iconPath = EVENT_ICONS[entry.event_type] || EVENT_ICONS['login_success'] + const method = getMethodLabel(entry) + const reason = getFailureReason(entry) + const browser = entry.user_agent ? parseBrowserName(entry.user_agent) : null + const os = entry.user_agent ? parseOS(entry.user_agent) : null + const deviceStr = [browser, os].filter(Boolean).join(' on ') + + return ( +
+
+ + + +
+ +
+
+ + {label} + + {method && ( + + {method} + + )} + {entry.outcome === 'failure' && ( + + Failed + + )} +
+
+ {reason && {reason}} + {reason && (deviceStr || entry.ip_address) && ·} + {deviceStr && {deviceStr}} + {deviceStr && entry.ip_address && ·} + {entry.ip_address && {entry.ip_address}} +
+
+ +
+ + {formatRelativeTime(entry.created_at)} + +
+
+ ) + })} + + {hasMore && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/components/settings/TrustedDevicesCard.tsx b/components/settings/TrustedDevicesCard.tsx new file mode 100644 index 0000000..21343c4 --- /dev/null +++ b/components/settings/TrustedDevicesCard.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useAuth } from '@/lib/auth/context' +import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices' +import { Spinner, toast } from '@ciphera-net/ui' + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + const diffHr = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHr / 24) + + if (diffMin < 1) return 'Just now' + if (diffMin < 60) return `${diffMin}m ago` + if (diffHr < 24) return `${diffHr}h ago` + if (diffDay < 7) return `${diffDay}d ago` + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }) +} + +function formatFullDate(dateStr: string): string { + return new Date(dateStr).toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function getDeviceIcon(hint: string): string { + const h = hint.toLowerCase() + if (h.includes('iphone') || h.includes('android') || h.includes('ios')) { + return 'M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3' + } + return 'M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z' +} + +export default function TrustedDevicesCard() { + const { user } = useAuth() + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [removingId, setRemovingId] = useState(null) + + const fetchDevices = useCallback(async () => { + try { + const data = await getUserDevices() + setDevices(data.devices ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load devices') + } + }, []) + + useEffect(() => { + if (!user) return + setLoading(true) + fetchDevices().finally(() => setLoading(false)) + }, [user, fetchDevices]) + + const handleRemove = async (device: TrustedDevice) => { + if (device.is_current) { + toast.error('You cannot remove the device you are currently using.') + return + } + + setRemovingId(device.id) + try { + await removeDevice(device.id) + setDevices(prev => prev.filter(d => d.id !== device.id)) + toast.success('Device removed. A new sign-in from it will trigger an alert.') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove device') + } finally { + setRemovingId(null) + } + } + + return ( +
+

Trusted Devices

+

+ Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert. +

+ + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : devices.length === 0 ? ( +
+ + + +

No trusted devices yet. They appear after you sign in.

+
+ ) : ( +
+ {devices.map((device) => ( +
+
+ + + +
+ +
+
+ + {device.display_hint || 'Unknown device'} + + {device.is_current && ( + + This device + + )} +
+
+ + First seen {formatRelativeTime(device.first_seen_at)} + + · + + Last seen {formatRelativeTime(device.last_seen_at)} + +
+
+ + {!device.is_current && ( + + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/lib/api/activity.ts b/lib/api/activity.ts new file mode 100644 index 0000000..894f45a --- /dev/null +++ b/lib/api/activity.ts @@ -0,0 +1,28 @@ +import apiRequest from './client' + +export interface AuditLogEntry { + id: string + created_at: string + event_type: string + outcome: string + ip_address?: string + user_agent?: string + metadata?: Record +} + +export interface ActivityResponse { + entries: AuditLogEntry[] | null + total_count: number + has_more: boolean + limit: number + offset: number +} + +export async function getUserActivity( + limit = 20, + offset = 0 +): Promise { + return apiRequest( + `/auth/user/activity?limit=${limit}&offset=${offset}` + ) +} diff --git a/lib/api/devices.ts b/lib/api/devices.ts new file mode 100644 index 0000000..501148f --- /dev/null +++ b/lib/api/devices.ts @@ -0,0 +1,19 @@ +import apiRequest from './client' + +export interface TrustedDevice { + id: string + display_hint: string + first_seen_at: string + last_seen_at: string + is_current: boolean +} + +export async function getUserDevices(): Promise<{ devices: TrustedDevice[] }> { + return apiRequest<{ devices: TrustedDevice[] }>('/auth/user/devices') +} + +export async function removeDevice(deviceId: string): Promise { + return apiRequest(`/auth/user/devices/${deviceId}`, { + method: 'DELETE', + }) +}