Merge pull request #38 from ciphera-net/staging

Settings page overhaul, auth resilience, and automated testing
This commit is contained in:
Usman
2026-03-01 14:05:56 +01:00
committed by GitHub
25 changed files with 3981 additions and 28 deletions

33
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# * Runs unit tests on push/PR to main and staging.
name: Test
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
permissions:
contents: read
packages: read
jobs:
test:
name: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.PKG_READ_TOKEN }}
- name: Run tests
run: npm test

View File

@@ -6,9 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
## [0.12.0-alpha] - 2026-03-01
### Added ### Added
- **Automated testing for improved reliability.** Pulse now has a comprehensive test suite that verifies critical parts of the app work correctly before every release. This covers login and session protection, error tracking, online/offline detection, and background data refreshing. These checks run automatically so regressions are caught before they reach you.
- **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again. - **App Switcher in User Menu.** Click your profile in the top right and you'll now see a "Ciphera Apps" section. Expand it to quickly jump between Pulse, Drop (file sharing), and your Ciphera Account settings. This makes it easier to discover and navigate between Ciphera products without signing in again.
- **Session synchronization across tabs.** When you sign out in one browser tab, you're now automatically signed out in all other tabs of the same app. This prevents situations where you might still appear signed in on another tab after logging out. The same applies to signing in — when you sign in on one tab, other tabs will update to reflect your authenticated state.
- **Session expiration warning.** You'll now see a heads-up banner 3 minutes before your session expires, giving you time to click "Stay signed in" to extend your session. If you ignore it or dismiss it, your session will end naturally after the 15-minute timeout for security. If you interact with the app (click, type, scroll) while the warning is showing, it automatically extends your session.
- **Faster billing page loading.** Your subscription details now load much quicker when you visit the billing page. Previously, several requests to our payment provider were made one after another, which could add 1-2 seconds to the page load. Now these happen simultaneously, cutting the wait time significantly. If any request takes too long, we gracefully continue so you always see your billing information without frustrating delays. - **Faster billing page loading.** Your subscription details now load much quicker when you visit the billing page. Previously, several requests to our payment provider were made one after another, which could add 1-2 seconds to the page load. Now these happen simultaneously, cutting the wait time significantly. If any request takes too long, we gracefully continue so you always see your billing information without frustrating delays.
- **Faster funnel analysis for multi-step conversions.** We've significantly improved how conversion funnels are calculated. Instead of scanning your data multiple times for each step in a funnel, we now do it in a single efficient pass. This means complex funnels with multiple steps load almost instantly instead of taking seconds—or even timing out. We've also added a reasonable limit of 5 steps per funnel to ensure optimal performance. - **Faster funnel analysis for multi-step conversions.** We've significantly improved how conversion funnels are calculated. Instead of scanning your data multiple times for each step in a funnel, we now do it in a single efficient pass. This means complex funnels with multiple steps load almost instantly instead of taking seconds—or even timing out. We've also added a reasonable limit of 5 steps per funnel to ensure optimal performance.
- **More reliable database connections under heavy load.** We've optimized how Pulse manages its database connections to handle much higher traffic without issues. By increasing the connection pool size and improving how connections are reused, your dashboard stays responsive even when thousands of users are viewing analytics simultaneously. We also added better monitoring so we can detect and address connection issues before they affect you. - **More reliable database connections under heavy load.** We've optimized how Pulse manages its database connections to handle much higher traffic without issues. By increasing the connection pool size and improving how connections are reused, your dashboard stays responsive even when thousands of users are viewing analytics simultaneously. We also added better monitoring so we can detect and address connection issues before they affect you.
@@ -31,6 +36,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events. - **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events.
- **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes. - **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes.
### Changed
- **Request ID tracing for debugging.** All API requests now include a unique Request ID header (`X-Request-ID`) that helps trace requests across frontend and backend services. When errors occur, the Request ID is included in the response, making it easy to find the exact request in server logs for debugging.
- **App Switcher now shows consistent order.** The Ciphera Apps menu now always displays apps in the same order: Pulse, Drop, Auth — regardless of which app you're currently using. Previously, the current app was shown first, causing the order to change depending on context. This creates a more predictable navigation experience.
### Fixed ### Fixed
- **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading. - **Shopify and embedded site tracking.** The Pulse tracking script now loads correctly when embedded on third-party sites like Shopify stores, WooCommerce, or custom storefronts. Previously, tracking failed because the script was redirected to the login page instead of loading.
@@ -40,6 +50,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days. - **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days.
- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA. - **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA.
- **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise. - **More accurate visitor tracking.** We fixed rare edge cases where visitor counts could be slightly off during busy traffic spikes. Previously, the timestamp-based session ID generation could occasionally create overlapping identifiers. Every visitor now gets a truly unique UUID that never overlaps with others, ensuring your analytics are always precise.
- **More reliable background processing.** When multiple Pulse servers are running, background tasks like daily analytics calculations and data cleanup now coordinate more safely. Previously, under rare timing conditions, two servers could accidentally run the same task at the same time, which could lead to slightly inaccurate stats. Each server now holds a unique token that prevents one from interfering with another's work.
- **Cross-tab sign-out cleanup.** Signing out in one tab now fully clears your session data in all other tabs. Previously, some session-related entries were left behind, which could briefly show stale state before the redirect completed.
- **Settings sidebar highlight.** The "Manage Account" section in Settings now stays highlighted when you're viewing Trusted Devices or Security Activity. Previously, navigating to a sub-page removed the highlight from the parent section, making it unclear which group you were in.
- **More accurate readiness checks.** The service health endpoint now actively verifies that the cache and real-time tracker are reachable, not just configured. Previously, the readiness check only confirmed these services were set up—not that they were actually responding—so the API could report "ready" even when Redis or the tracker was down.
## [0.11.1-alpha] - 2026-02-23 ## [0.11.1-alpha] - 2026-02-23
@@ -209,7 +223,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
--- ---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...HEAD [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...HEAD
[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha
[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha [0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha
[0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha [0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha
[0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha [0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { NextRequest } from 'next/server'
import { middleware } from '../middleware'
function createRequest(path: string, cookies: Record<string, string> = {}): NextRequest {
const url = new URL(path, 'http://localhost:3000')
const req = new NextRequest(url)
for (const [name, value] of Object.entries(cookies)) {
req.cookies.set(name, value)
}
return req
}
describe('middleware', () => {
describe('public routes', () => {
const publicPaths = [
'/',
'/login',
'/signup',
'/auth/callback',
'/pricing',
'/features',
'/about',
'/faq',
'/changelog',
'/installation',
'/script.js',
]
publicPaths.forEach((path) => {
it(`allows unauthenticated access to ${path}`, () => {
const res = middleware(createRequest(path))
// NextResponse.next() does not set a Location header
expect(res.headers.get('Location')).toBeNull()
})
})
})
describe('public prefixes', () => {
it('allows /share/* without auth', () => {
const res = middleware(createRequest('/share/abc123'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /integrations without auth', () => {
const res = middleware(createRequest('/integrations'))
expect(res.headers.get('Location')).toBeNull()
})
it('allows /docs without auth', () => {
const res = middleware(createRequest('/docs'))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('protected routes', () => {
it('redirects unauthenticated users to /login', () => {
const res = middleware(createRequest('/sites'))
expect(res.headers.get('Location')).toContain('/login')
})
it('redirects unauthenticated users from /settings to /login', () => {
const res = middleware(createRequest('/settings'))
expect(res.headers.get('Location')).toContain('/login')
})
it('allows access with access_token cookie', () => {
const res = middleware(createRequest('/sites', { access_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
it('allows access with refresh_token cookie only', () => {
const res = middleware(createRequest('/sites', { refresh_token: 'tok' }))
expect(res.headers.get('Location')).toBeNull()
})
})
describe('auth-only route redirects', () => {
it('redirects authenticated user from /login to /', () => {
const res = middleware(createRequest('/login', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('redirects authenticated user from /signup to /', () => {
const res = middleware(createRequest('/signup', { access_token: 'tok' }))
const location = res.headers.get('Location')
expect(location).not.toBeNull()
expect(new URL(location!).pathname).toBe('/')
})
it('does NOT redirect from /login with only refresh_token (stale session)', () => {
const res = middleware(createRequest('/login', { refresh_token: 'tok' }))
// Should allow through to /login since only refresh_token is present
expect(res.headers.get('Location')).toBeNull()
})
})
})

View File

@@ -0,0 +1,532 @@
'use client'
import { useState, useEffect } 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 { updateUserPreferences } from '@/lib/api/user'
import { motion, AnimatePresence } from 'framer-motion'
import {
UserIcon,
LockIcon,
BoxIcon,
ChevronRightIcon,
ChevronDownIcon,
ExternalLinkIcon,
} from '@ciphera-net/ui'
// Inline SVG icons not available in ciphera-ui
function BellIcon({ className }: { className?: string }) {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
)
}
// --- Types ---
type ProfileSubTab = 'profile' | 'security' | 'preferences'
type NotificationSubTab = 'security' | 'center'
type ActiveSelection =
| { section: 'profile'; subTab: ProfileSubTab }
| { section: 'notifications'; subTab: NotificationSubTab }
| { section: 'account' }
| { section: 'devices' }
| { section: 'activity' }
type ExpandableSection = 'profile' | 'notifications' | 'account'
// --- Sidebar Components ---
function SectionHeader({
expanded,
active,
onToggle,
icon: Icon,
label,
description,
hasChildren = true,
}: {
expanded: boolean
active: boolean
onToggle: () => void
icon: React.ElementType
label: string
description?: string
hasChildren?: boolean
}) {
return (
<button
onClick={onToggle}
className={`w-full flex items-start gap-3 px-4 py-3 text-left rounded-xl transition-all duration-200 ${
active
? 'bg-brand-orange/10 text-brand-orange'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<Icon className="w-5 h-5 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="font-medium">{label}</span>
{description && (
<p className={`text-xs mt-0.5 ${active ? 'text-brand-orange/70' : 'text-neutral-500'}`}>
{description}
</p>
)}
</div>
{hasChildren ? (
<ChevronDownIcon
className={`w-4 h-4 shrink-0 mt-1 transition-transform duration-200 ${
expanded ? '' : '-rotate-90'
}`}
/>
) : (
<ChevronRightIcon className={`w-4 h-4 shrink-0 mt-1 transition-transform ${active ? 'rotate-90' : ''}`} />
)}
</button>
)
}
function SubItem({
active,
onClick,
label,
external = false,
}: {
active: boolean
onClick: () => void
label: string
external?: boolean
}) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 pl-12 pr-4 py-2 text-sm text-left rounded-lg transition-all duration-150 ${
active
? 'text-brand-orange font-medium bg-brand-orange/5'
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
}`}
>
<span className="flex-1">{label}</span>
{external && <ExternalLinkIcon className="w-3 h-3 opacity-60" />}
</button>
)
}
function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
return (
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="py-1 space-y-0.5">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// --- Content Components ---
// Security Alerts Card (granular security toggles)
const SECURITY_ALERT_OPTIONS = [
{ key: 'login_alerts', label: 'Login Activity', description: 'New device sign-ins and suspicious login attempts.' },
{ key: 'password_alerts', label: 'Password Changes', description: 'Password changes and session revocations.' },
{ key: 'two_factor_alerts', label: 'Two-Factor Authentication', description: '2FA enabled/disabled and recovery code changes.' },
]
function SecurityAlertsCard() {
const { user } = useAuth()
const [emailNotifications, setEmailNotifications] = useState<Record<string, boolean>>({})
useEffect(() => {
if (user?.preferences?.email_notifications) {
setEmailNotifications(user.preferences.email_notifications)
} else {
const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({
...acc,
[option.key]: true
}), {} as Record<string, boolean>)
setEmailNotifications(defaults)
}
}, [user])
const handleToggle = async (key: string) => {
const newState = {
...emailNotifications,
[key]: !emailNotifications[key]
}
setEmailNotifications(newState)
try {
await updateUserPreferences({
email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean }
})
} catch {
setEmailNotifications(prev => ({
...prev,
[key]: !prev[key]
}))
}
}
return (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-brand-orange/10">
<BellIcon className="w-5 h-5 text-brand-orange" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Security Alerts</h2>
<p className="text-sm text-neutral-500">Choose which security events trigger email alerts</p>
</div>
</div>
<div className="space-y-4">
{SECURITY_ALERT_OPTIONS.map((item) => (
<div
key={item.key}
className={`flex items-center justify-between p-4 border rounded-xl transition-all duration-200 ${
emailNotifications[item.key]
? 'bg-orange-50 dark:bg-brand-orange/10 border-brand-orange shadow-sm'
: 'bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-800'
}`}
>
<div className="space-y-0.5">
<span className={`block text-sm font-medium transition-colors duration-200 ${
emailNotifications[item.key] ? 'text-brand-orange' : 'text-neutral-900 dark:text-white'
}`}>
{item.label}
</span>
<span className={`block text-xs transition-colors duration-200 ${
emailNotifications[item.key] ? 'text-brand-orange/80' : 'text-neutral-500 dark:text-neutral-400'
}`}>
{item.description}
</span>
</div>
<button
onClick={() => handleToggle(item.key)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
emailNotifications[item.key] ? 'bg-brand-orange' : 'bg-neutral-200 dark:bg-neutral-700'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
emailNotifications[item.key] ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
))}
</div>
</div>
)
}
function AccountManagementCard() {
const accountLinks = [
{
label: 'Profile & Personal Info',
description: 'Update your name, email, and avatar',
href: 'https://auth.ciphera.net/settings',
icon: UserIcon,
},
{
label: 'Security & 2FA',
description: 'Password, two-factor authentication, and passkeys',
href: 'https://auth.ciphera.net/settings?tab=security',
icon: LockIcon,
},
{
label: 'Active Sessions',
description: 'Manage devices logged into your account',
href: 'https://auth.ciphera.net/settings?tab=sessions',
icon: BoxIcon,
},
]
return (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 rounded-lg bg-brand-orange/10">
<UserIcon className="w-5 h-5 text-brand-orange" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Ciphera Account</h2>
<p className="text-sm text-neutral-500">Manage your account across all Ciphera products</p>
</div>
</div>
<div className="space-y-3">
{accountLinks.map((link) => (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-3 p-3 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/30 hover:bg-brand-orange/5 transition-all group"
>
<link.icon className="w-5 h-5 text-neutral-400 group-hover:text-brand-orange shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white group-hover:text-brand-orange">
{link.label}
</span>
<ExternalLinkIcon className="w-3.5 h-3.5 text-neutral-400" />
</div>
<p className="text-sm text-neutral-500 mt-0.5">{link.description}</p>
</div>
<ChevronRightIcon className="w-4 h-4 text-neutral-400 shrink-0 mt-1" />
</a>
))}
</div>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<p className="text-xs text-neutral-500">
These settings apply to your Ciphera Account and affect all products (Drop, Pulse, and Auth).
</p>
</div>
</div>
)
}
// --- Main Settings Section ---
function AppSettingsSection() {
const [active, setActive] = useState<ActiveSelection>({ section: 'profile', subTab: 'profile' })
const [expanded, setExpanded] = useState<Set<ExpandableSection>>(new Set(['profile']))
const toggleSection = (section: ExpandableSection) => {
setExpanded(prev => {
const next = new Set(prev)
if (next.has(section)) {
next.delete(section)
} else {
next.add(section)
}
return next
})
}
const selectSubTab = (selection: ActiveSelection) => {
setActive(selection)
if ('subTab' in selection) {
setExpanded(prev => new Set(prev).add(selection.section as ExpandableSection))
}
}
const renderContent = () => {
switch (active.section) {
case 'profile':
return <ProfileSettings activeTab={active.subTab} />
case 'notifications':
if (active.subTab === 'security') return <SecurityAlertsCard />
if (active.subTab === 'center') return (
<div className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm">
<div className="text-center max-w-md mx-auto">
<BellIcon className="w-12 h-12 text-neutral-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 dark:text-white mb-2">Notification Center</h3>
<p className="text-sm text-neutral-500 mb-4">
View and manage all your notifications in one place.
</p>
<Link
href="/notifications"
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-orange text-white rounded-lg hover:bg-brand-orange/90 transition-colors"
>
Open Notification Center
<ChevronRightIcon className="w-4 h-4" />
</Link>
</div>
</div>
)
return null
case 'account':
return <AccountManagementCard />
case 'devices':
return <TrustedDevicesCard />
case 'activity':
return <SecurityActivityCard />
default:
return null
}
}
return (
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Navigation */}
<nav className="w-full lg:w-72 flex-shrink-0 space-y-6">
{/* Pulse Settings Section */}
<div>
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
Pulse Settings
</h3>
<div className="space-y-1">
<div>
<SectionHeader
expanded={expanded.has('profile')}
active={active.section === 'profile'}
onToggle={() => {
toggleSection('profile')
if (!expanded.has('profile')) {
selectSubTab({ section: 'profile', subTab: 'profile' })
}
}}
icon={UserIcon}
label="Profile & Preferences"
description="Your profile and sharing defaults"
/>
<ExpandableSubItems expanded={expanded.has('profile')}>
<SubItem
active={active.section === 'profile' && active.subTab === 'profile'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'profile' })}
label="Profile"
/>
<SubItem
active={active.section === 'profile' && active.subTab === 'security'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'security' })}
label="Security"
/>
<SubItem
active={active.section === 'profile' && active.subTab === 'preferences'}
onClick={() => selectSubTab({ section: 'profile', subTab: 'preferences' })}
label="Preferences"
/>
</ExpandableSubItems>
</div>
{/* Notifications (expandable) */}
<div>
<SectionHeader
expanded={expanded.has('notifications')}
active={active.section === 'notifications'}
onToggle={() => {
toggleSection('notifications')
if (!expanded.has('notifications')) {
selectSubTab({ section: 'notifications', subTab: 'security' })
}
}}
icon={BellIcon}
label="Notifications"
description="Email and in-app notifications"
/>
<ExpandableSubItems expanded={expanded.has('notifications')}>
<SubItem
active={active.section === 'notifications' && active.subTab === 'security'}
onClick={() => selectSubTab({ section: 'notifications', subTab: 'security' })}
label="Security Alerts"
/>
<SubItem
active={active.section === 'notifications' && active.subTab === 'center'}
onClick={() => selectSubTab({ section: 'notifications', subTab: 'center' })}
label="Notification Center"
/>
</ExpandableSubItems>
</div>
</div>
</div>
{/* Ciphera Account Section */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-800">
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3 px-4">
Ciphera Account
</h3>
<div>
<SectionHeader
expanded={expanded.has('account')}
active={active.section === 'account' || active.section === 'devices' || active.section === 'activity'}
onToggle={() => {
toggleSection('account')
if (!expanded.has('account')) {
setActive({ section: 'account' })
}
}}
icon={LockIcon}
label="Manage Account"
description="Security, 2FA, and sessions"
/>
<ExpandableSubItems expanded={expanded.has('account')}>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings', '_blank')}
label="Profile & Personal Info"
external
/>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings?tab=security', '_blank')}
label="Security & 2FA"
external
/>
<SubItem
active={false}
onClick={() => window.open('https://auth.ciphera.net/settings?tab=sessions', '_blank')}
label="Active Sessions"
external
/>
<SubItem
active={active.section === 'devices'}
onClick={() => setActive({ section: 'devices' })}
label="Trusted Devices"
/>
<SubItem
active={active.section === 'activity'}
onClick={() => setActive({ section: 'activity' })}
label="Security Activity"
/>
</ExpandableSubItems>
</div>
</div>
</nav>
{/* Content Area */}
<div className="flex-1 min-w-0">
{renderContent()}
</div>
</div>
)
}
export default function SettingsPageClient() {
const { user } = useAuth()
return (
<div className="space-y-8">
{/* Page Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
Manage your Pulse preferences and Ciphera account settings
</p>
</div>
{/* Breadcrumb / Context */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<span>You are signed in as</span>
<span className="font-medium text-neutral-900 dark:text-white">{user?.email}</span>
<span>&bull;</span>
<a
href="https://auth.ciphera.net/settings"
target="_blank"
rel="noopener noreferrer"
className="text-brand-orange hover:underline inline-flex items-center gap-1"
>
Manage in Ciphera Account
<ExternalLinkIcon className="w-3 h-3" />
</a>
</div>
{/* Settings Content */}
<AppSettingsSection />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import ProfileSettings from '@/components/settings/ProfileSettings' import SettingsPageClient from './SettingsPageClient'
export const metadata = { export const metadata = {
title: 'Settings - Pulse', title: 'Settings - Pulse',
@@ -8,7 +8,7 @@ export const metadata = {
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"> <div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
<ProfileSettings /> <SettingsPageClient />
</div> </div>
) )
} }

View File

@@ -8,7 +8,11 @@ import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, u
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa' import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn' import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
export default function ProfileSettings() { interface Props {
activeTab?: 'profile' | 'security' | 'preferences'
}
export default function ProfileSettings({ activeTab }: Props = {}) {
const { user, refresh, logout } = useAuth() const { user, refresh, logout } = useAuth()
if (!user) return null if (!user) return null
@@ -54,6 +58,9 @@ export default function ProfileSettings() {
deriveAuthKey={deriveAuthKey} deriveAuthKey={deriveAuthKey}
refreshUser={refresh} refreshUser={refresh}
logout={logout} logout={logout}
activeTab={activeTab}
hideNav={activeTab !== undefined}
hideNotifications
/> />
) )
} }

View File

@@ -0,0 +1,216 @@
'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'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
const PAGE_SIZE = 20
const EVENT_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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 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<AuditLogEntry[]>([])
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 (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No activity recorded yet.</p>
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={entry.id}
className="flex items-start gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className={`flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center mt-0.5 ${color}`}>
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white text-sm">
{label}
</span>
{method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
{method}
</span>
)}
{entry.outcome === 'failure' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-950/40 text-red-600 dark:text-red-400">
Failed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 flex-wrap">
{reason && <span>{reason}</span>}
{reason && (deviceStr || entry.ip_address) && <span>&middot;</span>}
{deviceStr && <span>{deviceStr}</span>}
{deviceStr && entry.ip_address && <span>&middot;</span>}
{entry.ip_address && <span>{entry.ip_address}</span>}
</div>
</div>
<div className="flex-shrink-0 text-right">
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatFullDate(entry.created_at)}>
{formatRelativeTime(entry.created_at)}
</span>
</div>
</div>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<button
type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
'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'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
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<TrustedDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [removingId, setRemovingId] = useState<string | null>(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 (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert.
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : devices.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="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" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No trusted devices yet. They appear after you sign in.</p>
</div>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white text-sm truncate">
{device.display_hint || 'Unknown device'}
</span>
{device.is_current && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-950/40 text-green-600 dark:text-green-400 flex-shrink-0">
This device
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
<span title={formatFullDate(device.first_seen_at)}>
First seen {formatRelativeTime(device.first_seen_at)}
</span>
<span>&middot;</span>
<span title={formatFullDate(device.last_seen_at)}>
Last seen {formatRelativeTime(device.last_seen_at)}
</span>
</div>
</div>
{!device.is_current && (
<button
type="button"
onClick={() => handleRemove(device)}
disabled={removingId === device.id}
className="flex-shrink-0 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{removingId === device.id ? 'Removing...' : 'Remove'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}

28
lib/api/activity.ts Normal file
View File

@@ -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<string, string>
}
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<ActivityResponse> {
return apiRequest<ActivityResponse>(
`/auth/user/activity?limit=${limit}&offset=${offset}`
)
}

View File

@@ -1,8 +1,10 @@
/** /**
* HTTP client wrapper for API calls * HTTP client wrapper for API calls
* Includes Request ID propagation for debugging across services
*/ */
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui' import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@ciphera-net/ui'
import { generateRequestId, getRequestIdHeader, setLastRequestId } from '@/lib/utils/requestId'
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */ /** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
const FETCH_TIMEOUT_MS = 30_000 const FETCH_TIMEOUT_MS = 30_000
@@ -180,8 +182,13 @@ async function apiRequest<T>(
? `${baseUrl}${endpoint}` ? `${baseUrl}${endpoint}`
: `${baseUrl}/api/v1${endpoint}` : `${baseUrl}/api/v1${endpoint}`
// * Generate and store request ID for tracing
const requestId = generateRequestId()
setLastRequestId(requestId)
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
[getRequestIdHeader()]: requestId,
} }
// * Merge any additional headers from options // * Merge any additional headers from options

19
lib/api/devices.ts Normal file
View File

@@ -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<void> {
return apiRequest<void>(`/auth/user/devices/${deviceId}`, {
method: 'DELETE',
})
}

View File

@@ -48,7 +48,9 @@ export interface UserPreferences {
email_notifications: { email_notifications: {
new_file_received: boolean new_file_received: boolean
file_downloaded: boolean file_downloaded: boolean
security_alerts: boolean login_alerts: boolean
password_alerts: boolean
two_factor_alerts: boolean
} }
} }

View File

@@ -3,7 +3,7 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import apiRequest from '@/lib/api/client' import apiRequest from '@/lib/api/client'
import { LoadingOverlay } from '@ciphera-net/ui' import { LoadingOverlay, useSessionSync, SessionExpiryWarning } from '@ciphera-net/ui'
import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth'
import { getUserOrganizations, switchContext } from '@/lib/api/organization' import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { logger } from '@/lib/utils/logger' import { logger } from '@/lib/utils/logger'
@@ -19,7 +19,9 @@ interface User {
email_notifications?: { email_notifications?: {
new_file_received: boolean new_file_received: boolean
file_downloaded: boolean file_downloaded: boolean
security_alerts: boolean login_alerts: boolean
password_alerts: boolean
two_factor_alerts: boolean
} }
} }
} }
@@ -49,9 +51,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
}
return res.ok
} catch {
return false
}
}, [])
const login = (userData: User) => { const login = (userData: User) => {
// * We still store user profile in localStorage for optimistic UI, but NOT the token // * We still store user profile in localStorage for optimistic UI, but NOT the token
localStorage.setItem('user', JSON.stringify(userData)) localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
setUser(userData) setUser(userData)
router.refresh() router.refresh()
// * Fetch full profile (including display_name) so header shows correct name without page refresh // * Fetch full profile (including display_name) so header shows correct name without page refresh
@@ -74,10 +92,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoggingOut(true) setIsLoggingOut(true)
await logoutAction() await logoutAction()
localStorage.removeItem('user') localStorage.removeItem('user')
// * Clear legacy tokens if they exist localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('token') localStorage.removeItem('ciphera_last_activity')
localStorage.removeItem('refreshToken') // * Broadcast logout to other tabs (BroadcastChannel will handle if available)
if (typeof window !== 'undefined' && 'BroadcastChannel' in window) {
const channel = new BroadcastChannel('ciphera_session')
channel.postMessage({ type: 'LOGOUT' })
channel.close()
}
setTimeout(() => { setTimeout(() => {
window.location.href = '/' window.location.href = '/'
}, 500) }, 500)
@@ -127,6 +149,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (session) { if (session) {
setUser(session) setUser(session)
localStorage.setItem('user', JSON.stringify(session)) localStorage.setItem('user', JSON.stringify(session))
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
// * Fetch full profile (including display_name) from API; preserve org_id/role from session // * Fetch full profile (including display_name) from API; preserve org_id/role from session
try { try {
const userData = await apiRequest<User>('/auth/user/me') const userData = await apiRequest<User>('/auth/user/me')
@@ -142,17 +165,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(null) setUser(null)
} }
// * Clear legacy tokens if they exist (migration)
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
setLoading(false) setLoading(false)
} }
init() init()
}, []) }, [])
// * Sync session across browser tabs using BroadcastChannel
useSessionSync({
onLogout: () => {
localStorage.removeItem('user')
localStorage.removeItem('ciphera_token_refreshed_at')
localStorage.removeItem('ciphera_last_activity')
window.location.href = '/'
},
onLogin: (userData) => {
setUser(userData as User)
router.refresh()
},
onRefresh: () => {
refresh()
},
})
// * Organization Wall & Auto-Switch // * Organization Wall & Auto-Switch
useEffect(() => { useEffect(() => {
const checkOrg = async () => { const checkOrg = async () => {
@@ -206,6 +240,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}> <AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
{isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />} {isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}
<SessionExpiryWarning
isAuthenticated={!!user}
onRefreshToken={refreshToken}
onExpired={logout}
/>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useOnlineStatus } from '../useOnlineStatus'
describe('useOnlineStatus', () => {
it('returns true initially', () => {
const { result } = renderHook(() => useOnlineStatus())
expect(result.current).toBe(true)
})
it('returns false when offline event fires', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
})
it('returns true when online event fires after offline', () => {
const { result } = renderHook(() => useOnlineStatus())
act(() => {
window.dispatchEvent(new Event('offline'))
})
expect(result.current).toBe(false)
act(() => {
window.dispatchEvent(new Event('online'))
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useVisibilityPolling } from '../useVisibilityPolling'
describe('useVisibilityPolling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('starts polling and calls callback at the visible interval', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
// Initial call might not happen immediately; advance to trigger interval
act(() => {
vi.advanceTimersByTime(1000)
})
expect(callback).toHaveBeenCalled()
})
it('reports isPolling as true when active', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
expect(result.current.isPolling).toBe(true)
})
it('calls callback multiple times over multiple intervals', () => {
const callback = vi.fn()
renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 500,
hiddenInterval: null,
})
)
act(() => {
vi.advanceTimersByTime(1500)
})
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2)
})
it('triggerPoll calls callback immediately', () => {
const callback = vi.fn()
const { result } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 10000,
hiddenInterval: null,
})
)
act(() => {
result.current.triggerPoll()
})
expect(callback).toHaveBeenCalled()
expect(result.current.lastPollTime).not.toBeNull()
})
it('cleans up intervals on unmount', () => {
const callback = vi.fn()
const { unmount } = renderHook(() =>
useVisibilityPolling(callback, {
visibleInterval: 1000,
hiddenInterval: null,
})
)
unmount()
callback.mockClear()
act(() => {
vi.advanceTimersByTime(5000)
})
expect(callback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getRequestIdFromError,
formatErrorMessage,
logErrorWithRequestId,
getSupportMessage,
} from '../errorHandler'
import { setLastRequestId, clearLastRequestId } from '../requestId'
beforeEach(() => {
clearLastRequestId()
})
describe('getRequestIdFromError', () => {
it('extracts request ID from error response body', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
expect(getRequestIdFromError(errorData)).toBe('REQ123_abc')
})
it('falls back to last stored request ID when not in response', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError({ error: {} })).toBe('REQfallback_xyz')
})
it('falls back to last stored request ID when no error data', () => {
setLastRequestId('REQfallback_xyz')
expect(getRequestIdFromError()).toBe('REQfallback_xyz')
})
it('returns null when no ID available anywhere', () => {
expect(getRequestIdFromError()).toBeNull()
})
})
describe('formatErrorMessage', () => {
it('returns plain message when no request ID available', () => {
expect(formatErrorMessage('Something failed')).toBe('Something failed')
})
it('appends request ID in development mode', () => {
const original = process.env.NODE_ENV
process.env.NODE_ENV = 'development'
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed')
expect(msg).toContain('Something failed')
expect(msg).toContain('REQ123_abc')
process.env.NODE_ENV = original
})
it('appends request ID when showRequestId option is set', () => {
setLastRequestId('REQ123_abc')
const msg = formatErrorMessage('Something failed', undefined, { showRequestId: true })
expect(msg).toContain('REQ123_abc')
})
})
describe('logErrorWithRequestId', () => {
it('logs with request ID when available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
setLastRequestId('REQ123_abc')
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith(
expect.stringContaining('REQ123_abc'),
expect.any(Error)
)
spy.mockRestore()
})
it('logs without request ID when not available', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
logErrorWithRequestId('TestContext', new Error('fail'))
expect(spy).toHaveBeenCalledWith('[TestContext]', expect.any(Error))
spy.mockRestore()
})
})
describe('getSupportMessage', () => {
it('includes request ID when available', () => {
const errorData = { error: { request_id: 'REQ123_abc' } }
const msg = getSupportMessage(errorData)
expect(msg).toContain('REQ123_abc')
expect(msg).toContain('contact support')
})
it('returns generic message when no request ID', () => {
const msg = getSupportMessage()
expect(msg).toBe('If this persists, please contact support.')
})
})

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('logger', () => {
beforeEach(() => {
vi.resetModules()
})
it('calls console.error in development', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
const { logger } = await import('../logger')
logger.error('test error')
expect(spy).toHaveBeenCalledWith('test error')
spy.mockRestore()
})
it('calls console.warn in development', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.NODE_ENV = 'development'
const { logger } = await import('../logger')
logger.warn('test warning')
expect(spy).toHaveBeenCalledWith('test warning')
spy.mockRestore()
})
})

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
generateRequestId,
getRequestIdHeader,
setLastRequestId,
getLastRequestId,
clearLastRequestId,
} from '../requestId'
describe('generateRequestId', () => {
it('returns a string starting with REQ', () => {
const id = generateRequestId()
expect(id).toMatch(/^REQ/)
})
it('contains a timestamp and random segment separated by underscore', () => {
const id = generateRequestId()
const parts = id.replace('REQ', '').split('_')
expect(parts).toHaveLength(2)
expect(parts[0].length).toBeGreaterThan(0)
expect(parts[1].length).toBeGreaterThan(0)
})
it('generates unique IDs across calls', () => {
const ids = new Set(Array.from({ length: 100 }, () => generateRequestId()))
expect(ids.size).toBe(100)
})
})
describe('getRequestIdHeader', () => {
it('returns X-Request-ID', () => {
expect(getRequestIdHeader()).toBe('X-Request-ID')
})
})
describe('lastRequestId storage', () => {
beforeEach(() => {
clearLastRequestId()
})
it('returns null when no ID has been set', () => {
expect(getLastRequestId()).toBeNull()
})
it('stores and retrieves a request ID', () => {
setLastRequestId('REQ123_abc')
expect(getLastRequestId()).toBe('REQ123_abc')
})
it('overwrites previous ID on set', () => {
setLastRequestId('first')
setLastRequestId('second')
expect(getLastRequestId()).toBe('second')
})
it('clears the stored ID', () => {
setLastRequestId('REQ123_abc')
clearLastRequestId()
expect(getLastRequestId()).toBeNull()
})
})

79
lib/utils/errorHandler.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Error handling utilities with Request ID extraction
* Helps users report errors with traceable IDs for support
*/
import { getLastRequestId } from './requestId'
interface ApiErrorResponse {
error?: {
code?: string
message?: string
details?: unknown
request_id?: string
}
}
/**
* Extract request ID from error response or use last known request ID
*/
export function getRequestIdFromError(errorData?: ApiErrorResponse): string | null {
// * Try to get from error response body
if (errorData?.error?.request_id) {
return errorData.error.request_id
}
// * Fallback to last request ID stored during API call
return getLastRequestId()
}
/**
* Format error message for display with optional request ID
* Shows request ID in development or for specific error types
*/
export function formatErrorMessage(
message: string,
errorData?: ApiErrorResponse,
options: { showRequestId?: boolean } = {}
): string {
const requestId = getRequestIdFromError(errorData)
// * Always show request ID in development
const isDev = process.env.NODE_ENV === 'development'
if (requestId && (isDev || options.showRequestId)) {
return `${message}\n\nRequest ID: ${requestId}`
}
return message
}
/**
* Log error with request ID for debugging
*/
export function logErrorWithRequestId(
context: string,
error: unknown,
errorData?: ApiErrorResponse
): void {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
console.error(`[${context}] Request ID: ${requestId}`, error)
} else {
console.error(`[${context}]`, error)
}
}
/**
* Get support message with request ID for user reports
*/
export function getSupportMessage(errorData?: ApiErrorResponse): string {
const requestId = getRequestIdFromError(errorData)
if (requestId) {
return `If this persists, contact support with Request ID: ${requestId}`
}
return 'If this persists, please contact support.'
}

30
lib/utils/formatDate.ts Normal file
View File

@@ -0,0 +1,30 @@
export 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,
})
}
export 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',
})
}

49
lib/utils/requestId.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Request ID utilities for tracing API calls across services
* Request IDs help debug issues by correlating logs across frontend and backends
*
* IMPORTANT: This module stores mutable state (lastRequestId) at module scope.
* This is safe because apiRequest (the only caller) runs exclusively in the
* browser where JS is single-threaded. If this ever needs server-side use,
* replace the module variable with AsyncLocalStorage.
*/
const REQUEST_ID_HEADER = 'X-Request-ID'
/**
* Generate a unique request ID
* Format: REQ<timestamp>_<random>
*/
export function generateRequestId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 8)
return `REQ${timestamp}_${random}`
}
/**
* Get request ID header name
*/
export function getRequestIdHeader(): string {
return REQUEST_ID_HEADER
}
/**
* Store the last request ID for error reporting.
* Browser-only — single-threaded, no concurrency risk.
*/
let lastRequestId: string | null = null
export function setLastRequestId(id: string): void {
lastRequestId = id
}
export function getLastRequestId(): string | null {
return lastRequestId
}
/**
* Clear the stored request ID
*/
export function clearLastRequestId(): void {
lastRequestId = null
}

2338
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,18 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.11.1-alpha", "version": "0.12.0-alpha",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.69", "@ciphera-net/ui": "^0.0.78",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
@@ -44,16 +46,21 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-scale": "^4.0.9", "@types/d3-scale": "^4.0.9",
"@types/node": "^20.14.12", "@types/node": "^20.14.12",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-simple-maps": "^3.0.6", "@types/react-simple-maps": "^3.0.6",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "^16.1.1", "eslint-config-next": "^16.1.1",
"jsdom": "^28.1.0",
"postcss": "^8.4.40", "postcss": "^8.4.40",
"tailwindcss": "^3.4.7", "tailwindcss": "^3.4.7",
"typescript": "5.9.3" "typescript": "5.9.3",
"vitest": "^4.0.18"
} }
} }

18
vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/__tests__/**/*.test.{ts,tsx}', '**/*.test.{ts,tsx}'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
})

1
vitest.setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'