Merge pull request #38 from ciphera-net/staging
Settings page overhaul, auth resilience, and automated testing
This commit is contained in:
33
.github/workflows/test.yml
vendored
Normal file
33
.github/workflows/test.yml
vendored
Normal 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
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
99
__tests__/middleware.test.ts
Normal file
99
__tests__/middleware.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
532
app/settings/SettingsPageClient.tsx
Normal file
532
app/settings/SettingsPageClient.tsx
Normal 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>•</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
216
components/settings/SecurityActivityCard.tsx
Normal file
216
components/settings/SecurityActivityCard.tsx
Normal 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>·</span>}
|
||||||
|
{deviceStr && <span>{deviceStr}</span>}
|
||||||
|
{deviceStr && entry.ip_address && <span>·</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
components/settings/TrustedDevicesCard.tsx
Normal file
130
components/settings/TrustedDevicesCard.tsx
Normal 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>·</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
28
lib/api/activity.ts
Normal 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}`
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
19
lib/api/devices.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal file
34
lib/hooks/__tests__/useOnlineStatus.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal file
99
lib/hooks/__tests__/useVisibilityPolling.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
95
lib/utils/__tests__/errorHandler.test.ts
Normal file
95
lib/utils/__tests__/errorHandler.test.ts
Normal 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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
lib/utils/__tests__/logger.test.ts
Normal file
29
lib/utils/__tests__/logger.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
61
lib/utils/__tests__/requestId.test.ts
Normal file
61
lib/utils/__tests__/requestId.test.ts
Normal 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
79
lib/utils/errorHandler.ts
Normal 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
30
lib/utils/formatDate.ts
Normal 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
49
lib/utils/requestId.ts
Normal 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
2338
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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
18
vitest.config.ts
Normal 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
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
Reference in New Issue
Block a user