Compare commits
63 Commits
0.11.0-alp
...
0.12.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da2472c86 | ||
|
|
29e84e3a4f | ||
|
|
b3a303d6df | ||
|
|
ac1ed58127 | ||
|
|
805617a290 | ||
|
|
6bb356697b | ||
|
|
9a39745323 | ||
|
|
b5f83ce582 | ||
|
|
bce56fa64d | ||
|
|
5ef6eafc63 | ||
|
|
15f82eee00 | ||
|
|
7053cf5d5e | ||
|
|
c4e95268fe | ||
|
|
fcd36dcaeb | ||
|
|
c436680876 | ||
|
|
cba6347d70 | ||
|
|
ba24c24f41 | ||
|
|
22bc18a7cc | ||
|
|
a928d2577b | ||
|
|
8589842f16 | ||
|
|
3ff5ee4b6c | ||
|
|
67dcca660e | ||
|
|
d14911baf9 | ||
|
|
4e140c853f | ||
|
|
335cfc1a00 | ||
|
|
052c49ace2 | ||
|
|
f933c2fb71 | ||
|
|
908b8c0900 | ||
|
|
e5ad4cf2f6 | ||
|
|
b4b1348a94 | ||
|
|
0022e7b335 | ||
|
|
a9aaf24456 | ||
|
|
e7e217777a | ||
|
|
704a38f3df | ||
|
|
4cff0c621d | ||
|
|
36774cc995 | ||
|
|
3efd23b386 | ||
|
|
3aa0d7ae7c | ||
|
|
faa0bfe64a | ||
|
|
209ec1608a | ||
|
|
bcc02c93a0 | ||
|
|
f994141d64 | ||
|
|
86cc27a10c | ||
|
|
1edd78672e | ||
|
|
40fe34014c | ||
|
|
c89d9ce485 | ||
|
|
72745bd41a | ||
|
|
30b450cdb6 | ||
|
|
3fe20a4b1b | ||
|
|
b0c15d6464 | ||
|
|
892ba4cb11 | ||
|
|
2cb8ffddec | ||
|
|
801dc1d773 | ||
|
|
1484ade717 | ||
|
|
ef041d9a01 | ||
|
|
6fb4da5a69 | ||
|
|
3cb5416251 | ||
|
|
f62d142adb | ||
|
|
dd9d4c5ac2 | ||
|
|
27b3aa8380 | ||
|
|
b54af6c03a | ||
|
|
2889b0bb0a | ||
|
|
bd17bb45c4 |
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,5 +37,6 @@ next-env.d.ts
|
||||
|
||||
# PWA
|
||||
public/sw.js
|
||||
public/sw 2.js
|
||||
public/workbox-*.js
|
||||
public/swe-worker-*.js
|
||||
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -6,6 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.12.0-alpha] - 2026-03-01
|
||||
|
||||
### 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.
|
||||
- **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 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.
|
||||
- **Better support for growing teams and traffic.** We've added infrastructure improvements that allow Pulse to run smoothly across multiple servers. When you scale up to handle more traffic, our background processes—like daily analytics calculations and data cleanup—will coordinate automatically so they don't conflict with each other. This ensures reliable performance as your team and data grow.
|
||||
- **Smarter protection for heavy dashboard operations.** We've implemented a new tiered rate limiting system that treats complex dashboard queries differently from simple requests. Expensive operations—like loading your full dashboard with all its charts and data—now have their own dedicated limits to prevent anyone from accidentally overwhelming the system with too many rapid refreshes. This keeps everything running smoothly for everyone, especially during busy periods.
|
||||
- **Smarter caching for faster dashboard loading.** We've added intelligent caching headers to our API responses, so your browser can remember recently loaded data and show it instantly when you navigate between pages. This works alongside our existing server-side caching to make your dashboard feel even more responsive—especially when switching between different date ranges or sections.
|
||||
- **More flexible uptime monitoring.** We've made our uptime checker more adaptable to different needs. Instead of a fixed limit on how many websites we can check simultaneously, you can now configure this based on your requirements. This means faster uptime checks for busy sites with many monitors, while keeping things efficient for smaller setups.
|
||||
- **Smarter data cleanup for better performance.** We've improved how old analytics data is cleaned up to keep everything running smoothly. Instead of deleting large amounts of data all at once—which could slow things down—we now remove old data in small, efficient batches. This ensures your dashboard stays fast and responsive even as we clean up months of historical data behind the scenes.
|
||||
- **Faster analytics processing for all sites.** We've upgraded how your daily analytics are calculated behind the scenes. Instead of processing sites one by one, we now analyze multiple sites simultaneously using a smart parallel system. This means your daily stats—like visitor counts and page views—are updated more quickly and consistently, even as we handle data from thousands of websites.
|
||||
- **Lighter dashboard data transfers.** Your dashboard now loads data in smaller, focused pieces instead of one massive bundle. This means faster loading times—especially on slower connections—and your analytics appear section by section as they become ready, rather than making you wait for everything at once.
|
||||
- **Smarter data fetching.** Your dashboard now automatically prevents duplicate requests when multiple components ask for the same data at the same time. It also briefly caches recent responses, so switching between pages feels instant while still keeping everything up to date. This reduces server load and makes the app feel snappier.
|
||||
- **Smarter dashboard updates.** Your dashboard now knows when you're actively viewing it versus when it's in the background. When you switch to another tab, we intelligently slow down data refreshes to save resources, then instantly catch up when you return. This keeps your analytics current without putting unnecessary load on the system.
|
||||
- **Instant real-time visitor counts.** Your dashboard's "current visitors" counter now updates lightning-fast using an optimized tracking system. Instead of scanning your entire database, we maintain a live session index that shows active visitors in milliseconds—even when thousands of people are browsing your sites simultaneously.
|
||||
- **Faster event tracking.** Your analytics data is now captured instantly without slowing down your website. We've switched to asynchronous processing that collects events in batches of 100, so your visitors' page views and interactions are recorded with zero impact on their browsing experience, even during traffic spikes.
|
||||
- **Faster dashboard loading.** Your site analytics now load almost instantly, even during busy periods. Behind the scenes, we've added intelligent caching that remembers your dashboard data for 30 seconds and refreshes it automatically in the background. Real-time visitor counts are updated every 5 seconds so you always see current activity without waiting.
|
||||
- **Better data management for long-term performance.** We've restructured how your analytics data is stored so the app stays fast even as you collect months of data. Old data is now automatically organized by month and cleaned up efficiently based on your retention settings, keeping everything running smoothly no matter how much traffic you get.
|
||||
- **Smarter database indexing.** We've optimized how your analytics data is indexed, making common queries—like loading your dashboard or filtering by date—significantly faster. This also reduces storage overhead, keeping the app lean as your data grows.
|
||||
- **Faster dashboard statistics.** Loading stats for any date range is now much quicker. Instead of recalculating from scratch every time, we use pre-computed daily summaries so your analytics appear instantly, even for months of data.
|
||||
- **Performance insights. Track how fast your site loads with Core Web Vitals (page load speed, layout shifts, responsiveness). Turn it on in Site Settings → Data & Privacy to see a performance widget on your dashboard.
|
||||
- **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.
|
||||
|
||||
### 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
|
||||
|
||||
- **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.
|
||||
- **Opening Pulse from the Ciphera hub.** Clicking Pulse on the auth apps page (auth.ciphera.net/apps) now signs you in correctly instead of showing "Invalid state". Previously, leftover OAuth data from a past login attempt could block the session flow; the callback now detects redirects from the hub (no `state` in the URL), clears stale PKCE storage, and completes token exchange.
|
||||
- **Admin organizations list.** Organizations that created a site but never subscribed now appear in the admin list. Previously only orgs with a billing row were shown.
|
||||
- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
### Changed
|
||||
|
||||
- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history.
|
||||
|
||||
## [0.11.0-alpha] - 2026-02-22
|
||||
|
||||
### Added
|
||||
@@ -18,10 +73,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
|
||||
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
|
||||
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
|
||||
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down.
|
||||
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency.
|
||||
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results.
|
||||
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing.
|
||||
- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down, so your active sessions aren't cut off mid-action.
|
||||
- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and keeping queries fast even with many concurrent users.
|
||||
- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) and show a clear error instead of empty or confusing results.
|
||||
- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing; the limit keeps things fast while still giving you flexibility.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -29,8 +84,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
|
||||
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
|
||||
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
|
||||
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
|
||||
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle.
|
||||
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time and fewer edge cases slip through.
|
||||
- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle, reducing download size and speeding up page loads.
|
||||
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development.
|
||||
|
||||
### Fixed
|
||||
@@ -168,7 +223,9 @@ 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.0-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.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.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,19 +33,23 @@ interface UserPayload {
|
||||
/** Error type returned to client for mapping to user-facing copy (no sensitive details). */
|
||||
export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server'
|
||||
|
||||
export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) {
|
||||
export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) {
|
||||
try {
|
||||
// * IMPORTANT: credentials: 'include' is required to receive httpOnly cookies from Auth API
|
||||
// * The Auth API sets access_token, refresh_token, and csrf_token as httpOnly cookies
|
||||
// * We must forward these to the browser for cross-subdomain auth to work
|
||||
const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // * Critical: receives httpOnly cookies from Auth API
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: 'pulse-app',
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
code_verifier: codeVerifier || '',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -91,6 +95,50 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
// * Forward cookies from Auth API response to browser
|
||||
// * The Auth API sets httpOnly cookies on auth.ciphera.net - we need to mirror them on pulse.ciphera.net
|
||||
const setCookieHeaders = res.headers.getSetCookie()
|
||||
if (setCookieHeaders && setCookieHeaders.length > 0) {
|
||||
for (const cookieStr of setCookieHeaders) {
|
||||
// * Parse Set-Cookie header (format: name=value; attributes...)
|
||||
const [nameValue] = cookieStr.split(';')
|
||||
const [name, value] = nameValue.trim().split('=')
|
||||
|
||||
if (name && value) {
|
||||
// * Determine if httpOnly (default true for security)
|
||||
const isHttpOnly = cookieStr.toLowerCase().includes('httponly')
|
||||
// * Determine sameSite (default lax)
|
||||
const sameSiteMatch = cookieStr.match(/samesite=(\w+)/i)
|
||||
const sameSite = (sameSiteMatch?.[1]?.toLowerCase() as 'strict' | 'lax' | 'none') || 'lax'
|
||||
// * Extract max-age if present
|
||||
const maxAgeMatch = cookieStr.match(/max-age=(\d+)/i)
|
||||
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 60 * 60 * 24 * 30
|
||||
|
||||
cookieStore.set(name.trim(), decodeURIComponent(value.trim()), {
|
||||
httpOnly: isHttpOnly,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: sameSite,
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: maxAge
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Also check for CSRF token in response header (fallback)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
if (csrfToken && !cookieStore.get('csrf_token')) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
|
||||
45
app/admin/layout.tsx
Normal file
45
app/admin/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { getAdminMe } from '@/lib/api/admin'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
getAdminMe()
|
||||
.then((res) => {
|
||||
if (res.is_admin) {
|
||||
setIsAdmin(true)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
// Redirect to home if not admin
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsAdmin(false)
|
||||
router.push('/')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
if (isAdmin === null) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Checking access..." />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">Pulse Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
app/admin/orgs/[id]/page.tsx
Normal file
243
app/admin/orgs/[id]/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
|
||||
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
function formatDateTime(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { dateStyle: 'long' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
|
||||
}
|
||||
function addMonths(d: Date, months: number) {
|
||||
const out = new Date(d)
|
||||
out.setMonth(out.getMonth() + months)
|
||||
return out
|
||||
}
|
||||
function addYears(d: Date, years: number) {
|
||||
const out = new Date(d)
|
||||
out.setFullYear(out.getFullYear() + years)
|
||||
return out
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'solo', label: 'Solo' },
|
||||
{ value: 'team', label: 'Team' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
]
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'month', label: 'Monthly' },
|
||||
{ value: 'year', label: 'Yearly' },
|
||||
]
|
||||
|
||||
const LIMIT_OPTIONS = [
|
||||
{ value: '1000', label: '1k (Free)' },
|
||||
{ value: '10000', label: '10k (Solo)' },
|
||||
{ value: '100000', label: '100k (Team)' },
|
||||
{ value: '1000000', label: '1M (Business)' },
|
||||
{ value: '5000000', label: '5M' },
|
||||
{ value: '10000000', label: '10M' },
|
||||
]
|
||||
|
||||
export default function AdminOrgDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const orgId = params.id as string
|
||||
|
||||
const [org, setOrg] = useState<AdminOrgDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [planId, setPlanId] = useState('free')
|
||||
const [interval, setInterval] = useState('month')
|
||||
const [limit, setLimit] = useState('1000')
|
||||
const [periodEnd, setPeriodEnd] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (orgId) {
|
||||
getAdminOrg(orgId)
|
||||
.then((data) => {
|
||||
setOrg({ ...data.billing, sites: data.sites })
|
||||
setPlanId(data.billing.plan_id)
|
||||
setInterval(data.billing.billing_interval || 'month')
|
||||
setLimit(data.billing.pageview_limit.toString())
|
||||
|
||||
// Format date for input type="datetime-local" or similar
|
||||
if (data.billing.current_period_end) {
|
||||
setPeriodEnd(new Date(data.billing.current_period_end).toISOString().slice(0, 16))
|
||||
} else {
|
||||
// Default to 1 month from now
|
||||
setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to load organization')
|
||||
router.push('/admin/orgs')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [orgId, router])
|
||||
|
||||
const handleGrantPlan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!org) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await grantPlan(org.organization_id, {
|
||||
plan_id: planId,
|
||||
billing_interval: interval,
|
||||
pageview_limit: parseInt(limit),
|
||||
period_end: new Date(periodEnd).toISOString(),
|
||||
})
|
||||
toast.success('Plan granted successfully')
|
||||
router.refresh()
|
||||
// Reload data to show updates
|
||||
const data = await getAdminOrg(orgId)
|
||||
setOrg({ ...data.billing, sites: data.sites })
|
||||
} catch (error) {
|
||||
toast.error('Failed to grant plan')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organization..." />
|
||||
if (!org) return <div>Organization not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{org.business_name || 'Unnamed Organization'}
|
||||
</h2>
|
||||
<span className="text-sm font-mono text-neutral-500">{org.organization_id}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Current Status */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Current Status</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-neutral-500">Plan:</span>
|
||||
<span className="font-medium">{org.plan_id}</span>
|
||||
|
||||
<span className="text-neutral-500">Status:</span>
|
||||
<span className="font-medium">{org.subscription_status}</span>
|
||||
|
||||
<span className="text-neutral-500">Limit:</span>
|
||||
<span className="font-medium">{new Intl.NumberFormat().format(org.pageview_limit)}</span>
|
||||
|
||||
<span className="text-neutral-500">Interval:</span>
|
||||
<span className="font-medium">{org.billing_interval}</span>
|
||||
|
||||
<span className="text-neutral-500">Period End:</span>
|
||||
<span className="font-medium">
|
||||
{org.current_period_end ? formatDateTime(new Date(org.current_period_end)) : '-'}
|
||||
</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Cust:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_customer_id || '-'}</span>
|
||||
|
||||
<span className="text-neutral-500">Stripe Sub:</span>
|
||||
<span className="font-mono text-xs">{org.stripe_subscription_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sites */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Sites ({org.sites.length})</h3>
|
||||
<ul className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{org.sites.map((site) => (
|
||||
<li key={site.id} className="flex justify-between items-center text-sm p-2 bg-neutral-50 dark:bg-neutral-900 rounded">
|
||||
<span className="font-medium">{site.domain}</span>
|
||||
<span className="text-neutral-500 text-xs">{formatDate(new Date(site.created_at))}</span>
|
||||
</li>
|
||||
))}
|
||||
{org.sites.length === 0 && <li className="text-neutral-500 text-sm">No sites found</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grant Plan Form */}
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">Grant Plan (Manual Override)</h3>
|
||||
<form onSubmit={handleGrantPlan} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Plan Tier</label>
|
||||
<Select
|
||||
value={planId}
|
||||
onChange={setPlanId}
|
||||
options={PLAN_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Billing Interval</label>
|
||||
<Select
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pageview Limit</label>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
options={LIMIT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period End Date (UTC)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:ring-offset-2"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addMonths(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Month
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 1).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
+1 Year
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodEnd(addYears(new Date(), 100).toISOString().slice(0, 16))}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
Forever
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button type="submit" disabled={submitting} variant="primary">
|
||||
{submitting ? 'Granting...' : 'Grant Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
app/admin/orgs/page.tsx
Normal file
108
app/admin/orgs/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
|
||||
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
|
||||
|
||||
function formatDate(d: Date) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function CopyableOrgId({ id }: { id: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback(() => {
|
||||
navigator.clipboard.writeText(id)
|
||||
setCopied(true)
|
||||
toast.success('Org ID copied to clipboard')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [id])
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="font-mono text-xs text-neutral-500 hover:text-brand-orange dark:hover:text-brand-orange cursor-pointer transition-colors text-left"
|
||||
title="Click to copy"
|
||||
>
|
||||
{copied ? 'Copied!' : `${id.substring(0, 8)}...`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminOrgsPage() {
|
||||
const [orgs, setOrgs] = useState<AdminOrgSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
listAdminOrgs()
|
||||
.then(setOrgs)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Loading organizations..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white">Organizations</h2>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white mb-4">All Organizations</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-neutral-200 dark:border-neutral-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Name</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Org ID</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Plan</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Status</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Limit</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Updated</th>
|
||||
<th className="px-4 py-3 font-medium text-neutral-500 dark:text-neutral-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||
{orgs.map((org) => (
|
||||
<tr key={org.organization_id} className="hover:bg-neutral-50 dark:hover:bg-neutral-900/50">
|
||||
<td className="px-4 py-3 text-neutral-900 dark:text-white font-medium">
|
||||
{org.business_name || 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<CopyableOrgId id={org.organization_id} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
org.plan_id === 'business' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' :
|
||||
org.plan_id === 'team' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||
org.plan_id === 'solo' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||
'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-400'
|
||||
}`}>
|
||||
{org.plan_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{org.subscription_status || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-300">
|
||||
{new Intl.NumberFormat().format(org.pageview_limit)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-500 text-xs">
|
||||
{formatDate(new Date(org.updated_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orgs/${org.organization_id}`}>
|
||||
<Button variant="ghost">Manage</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
app/admin/page.tsx
Normal file
20
app/admin/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Link
|
||||
href="/admin/orgs"
|
||||
className="block transition-transform hover:scale-[1.02] rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Organizations</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Manage organization plans and limits</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-4">
|
||||
View all organizations, check billing status, and manually grant plans.
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export async function POST() {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// * Get CSRF token from Auth API response header (for cookie rotation)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
|
||||
cookieStore.set('access_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
@@ -55,6 +58,18 @@ export async function POST() {
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
|
||||
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
|
||||
if (csrfToken) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
|
||||
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
|
||||
import { exchangeAuthCode } from '@/app/actions/auth'
|
||||
import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
|
||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||
|
||||
@@ -21,7 +21,7 @@ function AuthCallbackContent() {
|
||||
const code = searchParams.get('code')
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
|
||||
if (!code || !codeVerifier) return
|
||||
if (!code) return
|
||||
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
|
||||
if (result.success && result.user) {
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
@@ -47,59 +47,28 @@ function AuthCallbackContent() {
|
||||
}, [searchParams, login, router])
|
||||
|
||||
useEffect(() => {
|
||||
// * Prevent double execution (React Strict Mode or fast re-renders)
|
||||
if (processedRef.current && !isRetrying) return
|
||||
|
||||
// * Check for direct token passing (from auth-frontend direct login)
|
||||
// * This flow exposes tokens in URL, kept for legacy support.
|
||||
// * Recommended: Use Authorization Code flow (below)
|
||||
const token = searchParams.get('token')
|
||||
const refreshToken = searchParams.get('refresh_token')
|
||||
|
||||
if (token && refreshToken) {
|
||||
processedRef.current = true
|
||||
const handleDirectTokens = async () => {
|
||||
const result = await setSessionAction(token, refreshToken)
|
||||
if (result.success && result.user) {
|
||||
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
|
||||
try {
|
||||
const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me')
|
||||
const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role }
|
||||
login(merged)
|
||||
} catch {
|
||||
login(result.user)
|
||||
}
|
||||
if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) {
|
||||
router.push('/welcome')
|
||||
} else {
|
||||
const raw = searchParams.get('returnTo') || '/'
|
||||
const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/'
|
||||
router.push(safe)
|
||||
}
|
||||
} else {
|
||||
setError(authMessageFromErrorType('invalid'))
|
||||
}
|
||||
}
|
||||
handleDirectTokens()
|
||||
return
|
||||
}
|
||||
|
||||
const code = searchParams.get('code')
|
||||
if (!code) return
|
||||
|
||||
const state = searchParams.get('state')
|
||||
|
||||
if (!code || !state) return
|
||||
|
||||
const storedState = localStorage.getItem('oauth_state')
|
||||
const codeVerifier = localStorage.getItem('oauth_code_verifier')
|
||||
|
||||
if (!codeVerifier) {
|
||||
setError('Missing code verifier')
|
||||
return
|
||||
}
|
||||
if (state !== storedState) {
|
||||
logger.error('State mismatch', { received: state, stored: storedState })
|
||||
setError('Invalid state')
|
||||
return
|
||||
// * Session flow (from auth hub): redirect has code but no state. Clear stale PKCE
|
||||
// * data from any previous app-initiated OAuth so exchange proceeds without validation.
|
||||
if (!state) {
|
||||
localStorage.removeItem('oauth_state')
|
||||
localStorage.removeItem('oauth_code_verifier')
|
||||
} else {
|
||||
// * Full OAuth flow (app-initiated): validate state + use PKCE
|
||||
const isFullOAuth = !!storedState && !!codeVerifier
|
||||
if (isFullOAuth && state !== storedState) {
|
||||
logger.error('State mismatch', { received: state, stored: storedState })
|
||||
setError('Invalid state')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
processedRef.current = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@ciphera-net/ui'
|
||||
import { Header, type CipheraApp } from '@ciphera-net/ui'
|
||||
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
@@ -16,6 +16,34 @@ import { useRouter } from 'next/navigation'
|
||||
|
||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||
|
||||
// * Available Ciphera apps for the app switcher
|
||||
const CIPHERA_APPS: CipheraApp[] = [
|
||||
{
|
||||
id: 'pulse',
|
||||
name: 'Pulse',
|
||||
description: 'Your current app — Privacy-first analytics',
|
||||
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
||||
href: 'https://pulse.ciphera.net',
|
||||
isAvailable: false, // * Current app
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
name: 'Drop',
|
||||
description: 'Secure file sharing',
|
||||
icon: 'https://ciphera.net/drop_icon_no_margins.png',
|
||||
href: 'https://drop.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
name: 'Auth',
|
||||
description: 'Your Ciphera account settings',
|
||||
icon: 'https://ciphera.net/auth_icon_no_margins.png',
|
||||
href: 'https://auth.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
]
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
@@ -87,6 +115,8 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
showPricing={true}
|
||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
|
||||
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 = {
|
||||
title: 'Settings - Pulse',
|
||||
@@ -8,7 +8,7 @@ export const metadata = {
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<ProfileSettings />
|
||||
<SettingsPageClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
@@ -148,6 +148,23 @@ export default function SiteDashboardPage() {
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [])
|
||||
|
||||
// * Visibility-aware polling intervals
|
||||
// * Historical data: 60s when visible, paused when hidden
|
||||
// * Real-time data: 5s when visible, 30s when hidden
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// * Track visibility state
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === 'visible'
|
||||
setIsVisible(visible)
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
@@ -204,18 +221,60 @@ export default function SiteDashboardPage() {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// Silently fail for realtime updates
|
||||
// * Silently fail for realtime updates
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
// * Visibility-aware polling for dashboard data (historical)
|
||||
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
|
||||
useEffect(() => {
|
||||
if (isSettingsLoaded) loadData()
|
||||
const interval = setInterval(() => {
|
||||
loadData(true)
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Initial load
|
||||
loadData()
|
||||
|
||||
// * Clear existing interval
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Only poll when visible (saves server resources when tab is backgrounded)
|
||||
if (isVisible) {
|
||||
dashboardIntervalRef.current = setInterval(() => {
|
||||
loadData(true)
|
||||
}, 60000) // * 60 seconds for historical data
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
|
||||
|
||||
// * Visibility-aware polling for realtime data
|
||||
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Clear existing interval
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Different intervals based on visibility
|
||||
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
loadRealtime()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
||||
}, interval)
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
|
||||
@@ -6,8 +6,13 @@ import api from '@/lib/api/client'
|
||||
import { deriveAuthKey } from '@/lib/crypto/password'
|
||||
import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, updateDisplayName } from '@/lib/api/user'
|
||||
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
|
||||
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()
|
||||
|
||||
if (!user) return null
|
||||
@@ -46,10 +51,16 @@ export default function ProfileSettings() {
|
||||
onRegenerateRecoveryCodes={regenerateRecoveryCodes}
|
||||
onGetSessions={getUserSessions}
|
||||
onRevokeSession={revokeSession}
|
||||
onRegisterPasskey={registerPasskey}
|
||||
onListPasskeys={listPasskeys}
|
||||
onDeletePasskey={deletePasskey}
|
||||
onUpdatePreferences={updateUserPreferences}
|
||||
deriveAuthKey={deriveAuthKey}
|
||||
refreshUser={refresh}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -27,14 +27,16 @@ export async function verify2FA(code: string): Promise<Verify2FAResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function disable2FA(): Promise<void> {
|
||||
export async function disable2FA(passwordDerived: string): Promise<void> {
|
||||
return apiRequest<void>('/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: passwordDerived }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodes(): Promise<RegenerateCodesResponse> {
|
||||
export async function regenerateRecoveryCodes(passwordDerived: string): Promise<RegenerateCodesResponse> {
|
||||
return apiRequest<RegenerateCodesResponse>('/auth/2fa/recovery', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: passwordDerived }),
|
||||
})
|
||||
}
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
62
lib/api/admin.ts
Normal file
62
lib/api/admin.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { authFetch } from './client'
|
||||
|
||||
export interface AdminOrgSummary {
|
||||
organization_id: string
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
subscription_status: string
|
||||
current_period_end: string
|
||||
business_name: string
|
||||
last_payment_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
domain: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminOrgDetail extends AdminOrgSummary {
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export interface GrantPlanParams {
|
||||
plan_id: string
|
||||
billing_interval: string
|
||||
pageview_limit: number
|
||||
period_end: string // ISO date string
|
||||
}
|
||||
|
||||
// Check if current user is admin
|
||||
export async function getAdminMe(): Promise<{ is_admin: boolean }> {
|
||||
try {
|
||||
return await authFetch<{ is_admin: boolean }>('/api/admin/me')
|
||||
} catch (e) {
|
||||
return { is_admin: false }
|
||||
}
|
||||
}
|
||||
|
||||
// List all organizations (admin view)
|
||||
export async function listAdminOrgs(): Promise<AdminOrgSummary[]> {
|
||||
const data = await authFetch<{ organizations: AdminOrgSummary[] }>('/api/admin/orgs')
|
||||
return data.organizations || []
|
||||
}
|
||||
|
||||
// Get details for a specific organization
|
||||
export async function getAdminOrg(orgId: string): Promise<{ billing: AdminOrgSummary; sites: Site[] }> {
|
||||
return await authFetch<{ billing: AdminOrgSummary; sites: Site[] }>(`/api/admin/orgs/${orgId}`)
|
||||
}
|
||||
|
||||
// Grant a plan to an organization manually
|
||||
export async function grantPlan(orgId: string, params: GrantPlanParams): Promise<void> {
|
||||
await authFetch(`/api/admin/orgs/${orgId}/grant-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* HTTP client wrapper for API calls
|
||||
* Includes Request ID propagation for debugging across services
|
||||
*/
|
||||
|
||||
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." */
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
@@ -22,6 +24,36 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
|
||||
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * CSRF Token Handling
|
||||
// * ============================================================================
|
||||
|
||||
/**
|
||||
* Get CSRF token from the csrf_token cookie (non-httpOnly)
|
||||
* This is needed for state-changing requests to the Auth API
|
||||
*/
|
||||
function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const cookies = document.cookie.split(';')
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=')
|
||||
if (name === 'csrf_token') {
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request method requires CSRF protection
|
||||
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
|
||||
*/
|
||||
function isStateChangingMethod(method: string): boolean {
|
||||
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
|
||||
return stateChangingMethods.includes(method.toUpperCase())
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data?: Record<string, unknown>
|
||||
@@ -58,50 +90,149 @@ function onRefreshFailed(err: unknown) {
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Request Deduplication & Caching
|
||||
// * ============================================================================
|
||||
|
||||
/** Cache TTL in milliseconds (2 seconds) */
|
||||
const CACHE_TTL_MS = 2_000
|
||||
|
||||
/** Stores in-flight requests for deduplication */
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>
|
||||
timestamp: number
|
||||
}
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
/** Stores cached responses */
|
||||
interface CachedResponse {
|
||||
data: unknown
|
||||
timestamp: number
|
||||
}
|
||||
const responseCache = new Map<string, CachedResponse>()
|
||||
|
||||
/**
|
||||
* Base API client with error handling
|
||||
* Generate a unique key for a request based on endpoint and options
|
||||
*/
|
||||
function getRequestKey(endpoint: string, options: RequestInit): string {
|
||||
const method = options.method || 'GET'
|
||||
const body = options.body || ''
|
||||
return `${method}:${endpoint}:${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from pending requests and response cache
|
||||
*/
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// * Clean up stale pending requests (older than 30 seconds)
|
||||
for (const [key, pending] of pendingRequests.entries()) {
|
||||
if (now - pending.timestamp > 30_000) {
|
||||
pendingRequests.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// * Clean up stale cached responses (older than CACHE_TTL_MS)
|
||||
for (const [key, cached] of responseCache.entries()) {
|
||||
if (now - cached.timestamp > CACHE_TTL_MS) {
|
||||
responseCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API client with error handling, request deduplication, and short-term caching
|
||||
*/
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// * Skip deduplication for non-GET requests (mutations should always execute)
|
||||
const method = options.method || 'GET'
|
||||
const shouldDedupe = method === 'GET'
|
||||
|
||||
if (shouldDedupe) {
|
||||
// * Clean up expired entries periodically
|
||||
if (pendingRequests.size > 100 || responseCache.size > 100) {
|
||||
cleanupExpiredEntries()
|
||||
}
|
||||
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Check if we have a recent cached response (within 2 seconds)
|
||||
const cached = responseCache.get(requestKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data as T
|
||||
}
|
||||
|
||||
// * Check if there's an identical request in flight
|
||||
const pending = pendingRequests.get(requestKey)
|
||||
if (pending && Date.now() - pending.timestamp < 30000) {
|
||||
return pending.promise as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// * Determine base URL
|
||||
const isAuthRequest = endpoint.startsWith('/auth')
|
||||
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
|
||||
|
||||
|
||||
// * Handle legacy endpoints that already include /api/ prefix
|
||||
const url = endpoint.startsWith('/api/')
|
||||
const url = endpoint.startsWith('/api/')
|
||||
? `${baseUrl}${endpoint}`
|
||||
: `${baseUrl}/api/v1${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
|
||||
// * Generate and store request ID for tracing
|
||||
const requestId = generateRequestId()
|
||||
setLastRequestId(requestId)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
[getRequestIdHeader()]: requestId,
|
||||
}
|
||||
|
||||
// * Merge any additional headers from options
|
||||
if (options.headers) {
|
||||
const additionalHeaders = options.headers as Record<string, string>
|
||||
Object.entries(additionalHeaders).forEach(([key, value]) => {
|
||||
headers[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
|
||||
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
|
||||
|
||||
// * Add CSRF token for state-changing requests to Auth API
|
||||
// * Auth API uses Double Submit Cookie pattern for CSRF protection
|
||||
if (isAuthRequest && isStateChangingMethod(method)) {
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
const signal = options.signal ?? controller.signal
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
// * Create the request promise
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -182,6 +313,38 @@ async function apiRequest<T>(
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})()
|
||||
|
||||
// * For GET requests, track the promise for deduplication and cache the result
|
||||
if (shouldDedupe) {
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Store in pending requests
|
||||
pendingRequests.set(requestKey, {
|
||||
promise: requestPromise as Promise<unknown>,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
// * Clean up pending request and cache the result when done
|
||||
requestPromise
|
||||
.then((data) => {
|
||||
// * Cache successful response
|
||||
responseCache.set(requestKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// * Remove from pending
|
||||
pendingRequests.delete(requestKey)
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
// * Remove from pending on error too
|
||||
pendingRequests.delete(requestKey)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
export const authFetch = apiRequest
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
261
lib/api/stats.ts
261
lib/api/stats.ts
@@ -332,11 +332,11 @@ export async function getDashboard(siteId: string, startDate?: string, endDate?:
|
||||
}
|
||||
|
||||
export async function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardData> {
|
||||
@@ -344,9 +344,256 @@ export async function getPublicDashboard(
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
|
||||
|
||||
appendAuthParams(params, { password, captcha })
|
||||
|
||||
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/public/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Focused Dashboard Endpoints (Fix 4.2: Efficient Data Transfer)
|
||||
// * These split the massive dashboard payload into smaller, focused chunks
|
||||
// * ============================================================================
|
||||
|
||||
export interface DashboardOverviewData {
|
||||
site: Site
|
||||
stats: Stats
|
||||
realtime_visitors: number
|
||||
daily_stats: DailyStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPagesData {
|
||||
top_pages: TopPage[]
|
||||
entry_pages: TopPage[]
|
||||
exit_pages: TopPage[]
|
||||
}
|
||||
|
||||
export async function getDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardLocationsData {
|
||||
countries: CountryStat[]
|
||||
cities: CityStat[]
|
||||
regions: RegionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardDevicesData {
|
||||
browsers: BrowserStat[]
|
||||
os: OSStat[]
|
||||
devices: DeviceStat[]
|
||||
screen_resolutions: ScreenResolutionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardReferrersData {
|
||||
top_referrers: TopReferrer[]
|
||||
}
|
||||
|
||||
export async function getDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPerformanceData {
|
||||
performance?: PerformanceStats
|
||||
performance_by_page?: PerformanceByPageStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ export interface UserPreferences {
|
||||
email_notifications: {
|
||||
new_file_received: boolean
|
||||
file_downloaded: boolean
|
||||
security_alerts: boolean
|
||||
login_alerts: boolean
|
||||
password_alerts: boolean
|
||||
two_factor_alerts: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
lib/api/webauthn.ts
Normal file
54
lib/api/webauthn.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* WebAuthn / Passkey API client for settings (list, register, delete).
|
||||
*/
|
||||
|
||||
import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||
import apiRequest from './client'
|
||||
|
||||
export interface BeginRegistrationResponse {
|
||||
sessionId: string
|
||||
creationOptions: {
|
||||
publicKey: Record<string, unknown>
|
||||
mediation?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ListPasskeysResponse {
|
||||
credentials: PasskeyCredential[]
|
||||
}
|
||||
|
||||
export async function registerPasskey(): Promise<void> {
|
||||
const { sessionId, creationOptions } = await apiRequest<BeginRegistrationResponse>(
|
||||
'/auth/webauthn/register/begin',
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const optionsJSON = creationOptions?.publicKey
|
||||
if (!optionsJSON) {
|
||||
throw new Error('Invalid registration options')
|
||||
}
|
||||
const response = await startRegistration({
|
||||
optionsJSON: optionsJSON as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
})
|
||||
await apiRequest<{ message: string }>('/auth/webauthn/register/finish', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId, response }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPasskeys(): Promise<ListPasskeysResponse> {
|
||||
return apiRequest<ListPasskeysResponse>('/auth/webauthn/credentials', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePasskey(credentialId: string): Promise<void> {
|
||||
return apiRequest<void>(
|
||||
`/auth/webauthn/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
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 { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
@@ -19,7 +19,9 @@ interface User {
|
||||
email_notifications?: {
|
||||
new_file_received: 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 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) => {
|
||||
// * We still store user profile in localStorage for optimistic UI, but NOT the token
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('ciphera_token_refreshed_at', Date.now().toString())
|
||||
setUser(userData)
|
||||
router.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)
|
||||
await logoutAction()
|
||||
localStorage.removeItem('user')
|
||||
// * Clear legacy tokens if they exist
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
|
||||
localStorage.removeItem('ciphera_token_refreshed_at')
|
||||
localStorage.removeItem('ciphera_last_activity')
|
||||
// * 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(() => {
|
||||
window.location.href = '/'
|
||||
}, 500)
|
||||
@@ -110,11 +132,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
// * 1. Check server-side session (cookies)
|
||||
const session = await getSessionAction()
|
||||
|
||||
let session = await getSessionAction()
|
||||
|
||||
// * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout)
|
||||
if (!session && typeof window !== 'undefined') {
|
||||
const refreshRes = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
})
|
||||
if (refreshRes.ok) {
|
||||
session = await getSessionAction()
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
setUser(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
|
||||
try {
|
||||
const userData = await apiRequest<User>('/auth/user/me')
|
||||
@@ -129,18 +164,29 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
// * Clear legacy tokens if they exist (migration)
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
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
|
||||
useEffect(() => {
|
||||
const checkOrg = async () => {
|
||||
@@ -194,6 +240,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
|
||||
{isLoggingOut && <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />}
|
||||
<SessionExpiryWarning
|
||||
isAuthenticated={!!user}
|
||||
onRefreshToken={refreshToken}
|
||||
onExpired={logout}
|
||||
/>
|
||||
{children}
|
||||
</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()
|
||||
})
|
||||
})
|
||||
128
lib/hooks/useVisibilityPolling.ts
Normal file
128
lib/hooks/useVisibilityPolling.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// * Custom hook for visibility-aware polling
|
||||
// * Pauses polling when tab is not visible, resumes when visible
|
||||
// * Reduces server load when users aren't actively viewing the dashboard
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseVisibilityPollingOptions {
|
||||
// * Polling interval when tab is visible (in milliseconds)
|
||||
visibleInterval: number
|
||||
// * Polling interval when tab is hidden (in milliseconds, or null to pause)
|
||||
hiddenInterval: number | null
|
||||
}
|
||||
|
||||
interface UseVisibilityPollingReturn {
|
||||
// * Whether polling is currently active
|
||||
isPolling: boolean
|
||||
// * Time since last poll
|
||||
lastPollTime: number | null
|
||||
// * Force a poll immediately
|
||||
triggerPoll: () => void
|
||||
}
|
||||
|
||||
export function useVisibilityPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
options: UseVisibilityPollingOptions
|
||||
): UseVisibilityPollingReturn {
|
||||
const { visibleInterval, hiddenInterval } = options
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [lastPollTime, setLastPollTime] = useState<number | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// * Keep callback reference up to date
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
// * Get current polling interval based on visibility
|
||||
const getInterval = useCallback((): number | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const isVisible = document.visibilityState === 'visible'
|
||||
if (isVisible) {
|
||||
return visibleInterval
|
||||
}
|
||||
return hiddenInterval
|
||||
}, [visibleInterval, hiddenInterval])
|
||||
|
||||
// * Start polling with current interval
|
||||
const startPolling = useCallback(() => {
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
setIsPolling(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPolling(true)
|
||||
|
||||
// * Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// * Set up new interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
}, interval)
|
||||
}, [getInterval])
|
||||
|
||||
// * Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setIsPolling(false)
|
||||
}, [])
|
||||
|
||||
// * Trigger immediate poll
|
||||
const triggerPoll = useCallback(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
|
||||
// * Restart polling timer
|
||||
startPolling()
|
||||
}, [startPolling])
|
||||
|
||||
// * Handle visibility changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// * Tab became visible - resume polling with visible interval
|
||||
startPolling()
|
||||
// * Trigger immediate poll to get fresh data
|
||||
triggerPoll()
|
||||
} else {
|
||||
// * Tab hidden - switch to hidden interval or pause
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
stopPolling()
|
||||
} else {
|
||||
// * Restart with hidden interval
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// * Start polling initially
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopPolling()
|
||||
}
|
||||
}, [startPolling, stopPolling, triggerPoll, getInterval])
|
||||
|
||||
return {
|
||||
isPolling,
|
||||
lastPollTime,
|
||||
triggerPoll,
|
||||
}
|
||||
}
|
||||
234
lib/swr/dashboard.ts
Normal file
234
lib/swr/dashboard.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// * SWR configuration for dashboard data fetching
|
||||
// * Implements stale-while-revalidate pattern for efficient data updates
|
||||
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
getDashboard,
|
||||
getDashboardOverview,
|
||||
getDashboardPages,
|
||||
getDashboardLocations,
|
||||
getDashboardDevices,
|
||||
getDashboardReferrers,
|
||||
getDashboardPerformance,
|
||||
getDashboardGoals,
|
||||
getRealtime,
|
||||
getStats,
|
||||
getDailyStats,
|
||||
} from '@/lib/api/stats'
|
||||
import { getSite } from '@/lib/api/sites'
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import type {
|
||||
Stats,
|
||||
DailyStat,
|
||||
DashboardOverviewData,
|
||||
DashboardPagesData,
|
||||
DashboardLocationsData,
|
||||
DashboardDevicesData,
|
||||
DashboardReferrersData,
|
||||
DashboardPerformanceData,
|
||||
DashboardGoalsData,
|
||||
} from '@/lib/api/stats'
|
||||
|
||||
// * SWR fetcher functions
|
||||
const fetchers = {
|
||||
site: (siteId: string) => getSite(siteId),
|
||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
|
||||
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
realtime: (siteId: string) => getRealtime(siteId),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
const dashboardSWRConfig = {
|
||||
// * Keep stale data visible while revalidating (better UX)
|
||||
revalidateOnFocus: false,
|
||||
// * Revalidate when reconnecting (fresh data after offline)
|
||||
revalidateOnReconnect: true,
|
||||
// * Retry failed requests
|
||||
shouldRetryOnError: true,
|
||||
errorRetryCount: 3,
|
||||
// * Error retry interval with exponential backoff
|
||||
errorRetryInterval: 5000,
|
||||
}
|
||||
|
||||
// * Hook for site data (loads once, refreshes rarely)
|
||||
export function useSite(siteId: string) {
|
||||
return useSWR<Site>(
|
||||
siteId ? ['site', siteId] : null,
|
||||
() => fetchers.site(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Site data changes rarely, refresh every 5 minutes
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 30 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for dashboard summary data (refreshed less frequently)
|
||||
export function useDashboard(siteId: string, start: string, end: string) {
|
||||
return useSWR(
|
||||
siteId && start && end ? ['dashboard', siteId, start, end] : null,
|
||||
() => fetchers.dashboard(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for dashboard summary
|
||||
refreshInterval: 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for daily stats (refreshed less frequently)
|
||||
export function useDailyStats(
|
||||
siteId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
interval: 'hour' | 'day' | 'minute'
|
||||
) {
|
||||
return useSWR<DailyStat[]>(
|
||||
siteId && start && end ? ['dailyStats', siteId, start, end, interval] : null,
|
||||
() => fetchers.dailyStats(siteId, start, end, interval),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for chart data
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for realtime visitor count (refreshed frequently)
|
||||
export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
return useSWR<{ visitors: number }>(
|
||||
siteId ? ['realtime', siteId] : null,
|
||||
() => fetchers.realtime(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh frequently for real-time data (default 5 seconds)
|
||||
refreshInterval,
|
||||
// * Short deduping for real-time
|
||||
dedupingInterval: 2000,
|
||||
// * Keep previous data while loading new data
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const PUBLIC_ROUTES = new Set([
|
||||
'/faq',
|
||||
'/changelog',
|
||||
'/installation',
|
||||
'/script.js', // * Tracking script – must load without auth for embedded sites (Shopify, etc.)
|
||||
])
|
||||
|
||||
const PUBLIC_PREFIXES = [
|
||||
@@ -34,8 +35,9 @@ export function middleware(request: NextRequest) {
|
||||
const hasRefresh = request.cookies.has('refresh_token')
|
||||
const hasSession = hasAccess || hasRefresh
|
||||
|
||||
// * Authenticated user hitting /login or /signup → send them home
|
||||
if (hasSession && AUTH_ONLY_ROUTES.has(pathname)) {
|
||||
// * Authenticated user (with access token) hitting /login or /signup → send them home.
|
||||
// * Only check access_token; stale refresh_token alone must not block login (fixes post-inactivity sign-in).
|
||||
if (hasAccess && AUTH_ONLY_ROUTES.has(pathname)) {
|
||||
return NextResponse.redirect(new URL('/', request.url))
|
||||
}
|
||||
|
||||
|
||||
2368
package-lock.json
generated
2368
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.11.0-alpha",
|
||||
"version": "0.12.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.58",
|
||||
"@ciphera-net/ui": "^0.0.78",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@stripe/react-stripe-js": "^5.6.0",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"axios": "^1.13.2",
|
||||
@@ -32,6 +35,7 @@
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -42,16 +46,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/node": "^20.14.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.1.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
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