63 Commits

Author SHA1 Message Date
Usman
3da2472c86 Merge pull request #38 from ciphera-net/staging
Settings page overhaul, auth resilience, and automated testing
2026-03-01 14:05:56 +01:00
Usman Baig
29e84e3a4f fix: remove outdated fixes from changelog for clarity 2026-03-01 14:02:31 +01:00
Usman Baig
b3a303d6df fix: improve session management and UI highlights 2026-03-01 13:53:54 +01:00
Usman Baig
ac1ed58127 fix: improve reliability of background processing across multiple Pulse servers 2026-03-01 13:45:00 +01:00
Usman Baig
805617a290 chore: update version to 0.12.0-alpha and document automated testing in changelog 2026-03-01 00:29:57 +01:00
Usman Baig
6bb356697b feat: update test workflow to use PKG_READ_TOKEN for NODE_AUTH_TOKEN 2026-03-01 00:20:17 +01:00
Usman Baig
9a39745323 feat: add NODE_AUTH_TOKEN environment variable for dependency installation 2026-03-01 00:13:57 +01:00
Usman Baig
b5f83ce582 feat: add unit tests and CI configuration 2026-03-01 00:11:54 +01:00
Usman Baig
bce56fa64d feat: implement refresh token functionality and update local storage management 2026-02-28 23:02:43 +01:00
Usman Baig
5ef6eafc63 feat: update notification preferences to include granular security alerts 2026-02-28 21:18:57 +01:00
Usman Baig
15f82eee00 feat: add email notification preferences and update settings page structure 2026-02-28 20:36:53 +01:00
Usman Baig
7053cf5d5e feat: add security activity and trusted devices management to settings page 2026-02-28 19:58:49 +01:00
Usman Baig
c4e95268fe feat: enhance settings page with account management and sidebar navigation 2026-02-28 19:13:09 +01:00
Usman Baig
fcd36dcaeb chore: update package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:57:39 +01:00
Usman Baig
c436680876 feat: add expandable sidebar navigation to settings page
Replace direct SharedProfileSettings rendering with an expandable sidebar
that shows Profile, Security, and Preferences as collapsible sub-items
under Profile & Preferences. Matches the new settings pattern across
all Ciphera frontends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:56:06 +01:00
Usman Baig
cba6347d70 Merge main into staging: remove sw 2.js, gitignore hygiene 2026-02-28 15:32:31 +01:00
Usman Baig
ba24c24f41 chore: remove duplicate sw 2.js and add to gitignore
- Remove public/sw 2.js (misnamed PWA build artifact)
- Ignore public/sw 2.js in .gitignore to prevent re-add

Made-with: Cursor
2026-02-28 15:32:25 +01:00
Usman Baig
22bc18a7cc chore: update CHANGELOG.md to include Request ID tracing for debugging, enhancing request tracking across services, and update API client to propagate Request ID in headers 2026-02-27 17:26:08 +01:00
Usman Baig
a928d2577b chore: update CHANGELOG.md to include consistent app order in the App Switcher for improved navigation experience, and update @ciphera-net/ui dependency to version 0.0.74 in package.json and package-lock.json 2026-02-27 15:03:44 +01:00
Usman Baig
8589842f16 chore: update CHANGELOG.md to include session expiration warning feature, enhancing user awareness, and update @ciphera-net/ui dependency to version 0.0.73 in package.json and package-lock.json 2026-02-27 14:24:07 +01:00
Usman Baig
3ff5ee4b6c chore: update CHANGELOG.md to include session synchronization across tabs feature, enhancing user experience, and update @ciphera-net/ui dependency to version 0.0.72 in package.json and package-lock.json 2026-02-27 14:15:40 +01:00
Usman Baig
67dcca660e chore: update @ciphera-net/ui dependency to version 0.0.71 in package.json and package-lock.json 2026-02-27 13:51:24 +01:00
Usman Baig
d14911baf9 chore: update @ciphera-net/ui dependency to version 0.0.70 in package.json and package-lock.json 2026-02-27 13:38:40 +01:00
Usman
4e140c853f Merge pull request #37 from ciphera-net/staging
Admin Dashboard enhancements, OAuth session fixes, and tracking script improvements
2026-02-27 13:27:36 +01:00
Usman
335cfc1a00 Merge branch 'main' into staging 2026-02-27 13:26:49 +01:00
Usman Baig
052c49ace2 chore: update @ciphera-net/ui dependency to version 0.0.69 in package.json and package-lock.json 2026-02-27 13:22:36 +01:00
Usman Baig
f933c2fb71 chore: update @ciphera-net/ui dependency to version 0.0.68 and update icon URLs in layout-content.tsx for improved asset management 2026-02-27 13:02:30 +01:00
Usman Baig
908b8c0900 chore: update CHANGELOG.md to include the addition of an App Switcher in the User Menu for easier navigation between Ciphera products, along with dependency updates for @ciphera-net/ui 2026-02-27 12:50:05 +01:00
Usman Baig
e5ad4cf2f6 chore: update CHANGELOG.md to reflect improvements in authentication flow, including seamless sign-in from the Ciphera portal and enhanced cookie management for better security and user experience 2026-02-27 12:05:49 +01:00
Usman Baig
b4b1348a94 chore: update CHANGELOG.md to include improvements in authentication flow, addressing CSRF handling and cookie management for seamless sign-in and enhanced security 2026-02-27 11:52:20 +01:00
Usman Baig
0022e7b335 chore: update CHANGELOG.md to clarify improvements in visitor tracking accuracy, ensuring unique identifiers for analytics during high traffic periods 2026-02-27 10:07:09 +01:00
Usman Baig
a9aaf24456 chore: update CHANGELOG.md to include multiple performance enhancements, such as faster billing page loading, improved funnel analysis, and more reliable database connections under heavy load 2026-02-27 10:04:13 +01:00
Usman Baig
e7e217777a chore: update CHANGELOG.md to include faster analytics processing for improved daily stats updates across multiple sites 2026-02-27 09:34:43 +01:00
Usman Baig
704a38f3df chore: update CHANGELOG.md to include lighter dashboard data transfers for improved loading times and new focused dashboard endpoints for efficient data retrieval 2026-02-27 09:24:01 +01:00
Usman Baig
4cff0c621d feat: implement request deduplication and caching in API client for improved performance 2026-02-27 09:17:51 +01:00
Usman Baig
36774cc995 chore: update CHANGELOG.md to include smarter data fetching with request deduplication and caching for improved performance 2026-02-27 09:13:29 +01:00
Usman Baig
3efd23b386 chore: update CHANGELOG.md to include enhancements for dashboard performance, including smarter updates, real-time visitor tracking, and faster event processing 2026-02-27 09:10:08 +01:00
Usman Baig
3aa0d7ae7c chore: update CHANGELOG.md to include faster dashboard statistics feature using pre-computed daily summaries for improved loading times 2026-02-27 08:49:23 +01:00
Usman Baig
faa0bfe64a chore: update CHANGELOG.md to include smarter database indexing for improved query performance and reduced storage overhead 2026-02-27 08:47:22 +01:00
Usman Baig
209ec1608a chore: update CHANGELOG.md to include better data management for long-term performance, enhancing analytics data storage and retrieval 2026-02-27 08:41:02 +01:00
Usman Baig
bcc02c93a0 chore: update CHANGELOG.md to highlight faster dashboard loading feature with intelligent caching for improved performance 2026-02-27 08:04:46 +01:00
Usman Baig
f994141d64 fix: improve Pulse tracking script for embedded sites like Shopify and WooCommerce 2026-02-26 14:44:24 +01:00
Usman Baig
86cc27a10c fix: resolve OAuth session flow issue when opening Pulse from the Ciphera hub 2026-02-26 14:40:42 +01:00
Usman Baig
1edd78672e fix: treat session-flow callback (no state) as valid when coming from auth hub
Made-with: Cursor
2026-02-26 14:26:54 +01:00
Usman Baig
40fe34014c style: update admin layout for improved responsiveness 2026-02-26 12:18:32 +01:00
Usman Baig
c89d9ce485 fix: add click-to-copy for org ID, show site name fallback in admin list
Made-with: Cursor
2026-02-26 12:16:07 +01:00
Usman Baig
72745bd41a [Fix] Admin organizations list - document org visibility fix
Made-with: Cursor
2026-02-25 22:18:21 +01:00
Usman Baig
30b450cdb6 Fix admin dashboard build: remove date-fns, replace Card with native divs, fix Button props
Made-with: Cursor
2026-02-25 22:02:53 +01:00
Usman Baig
3fe20a4b1b [Feat] Pulse Admin Dashboard - manually grant plans to organizations
- Add admin layout with /api/admin/me check and access denial
- Add admin pages: dashboard, orgs list, org detail with grant form
- Add lib/api/admin.ts client for admin endpoints

Made-with: Cursor
2026-02-25 21:58:23 +01:00
Usman Baig
b0c15d6464 Fix: allow script.js to load without auth for embedded sites (Shopify)
- Add /script.js to PUBLIC_ROUTES in middleware
- Fixes 307 redirect to /login when tracking script loads from third-party sites

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:15:09 +01:00
Usman
892ba4cb11 Merge pull request #36 from ciphera-net/staging
Performance insights, Goals & Events, 2FA improvements, auth fixes
2026-02-25 20:41:06 +01:00
Usman Baig
2cb8ffddec chore: update CHANGELOG.md to include new features, improvements, and fixes for performance insights, goals tracking, and enhanced error handling 2026-02-25 12:41:18 +01:00
Usman Baig
801dc1d773 chore: add @simplewebauthn/browser dependency to package.json and package-lock.json for WebAuthn support 2026-02-23 20:18:18 +01:00
Usman Baig
1484ade717 chore: update @ciphera-net/ui dependency to version 0.0.64 in package.json and package-lock.json 2026-02-23 19:33:49 +01:00
Usman Baig
ef041d9a01 chore: update @ciphera-net/ui dependency to version 0.0.63 in package.json 2026-02-23 19:10:07 +01:00
Usman Baig
6fb4da5a69 chore: update @ciphera-net/ui dependency to version 0.0.62 in package.json and package-lock.json 2026-02-23 19:00:08 +01:00
Usman Baig
3cb5416251 fix: implement automatic token refresh to prevent frequent re-logins, enhancing user experience during inactivity 2026-02-23 18:57:03 +01:00
Usman Baig
f62d142adb fix: resolve sign-in issue after inactivity by ensuring only valid access tokens trigger redirects, improving user experience 2026-02-23 18:46:46 +01:00
Usman Baig
dd9d4c5ac2 chore: update @ciphera-net/ui to version 0.0.61 for improved functionality 2026-02-23 18:04:10 +01:00
Usman Baig
27b3aa8380 feat: add 2FA recovery codes regeneration and backup functionality, enhancing account security 2026-02-23 11:43:57 +01:00
Usman Baig
b54af6c03a fix: require password confirmation to disable 2FA, enhancing security against session hijacking 2026-02-23 11:35:02 +01:00
Usman Baig
2889b0bb0a chore: update @ciphera-net/ui to 0.0.59 for improved functionality 2026-02-23 10:57:11 +01:00
Usman Baig
bd17bb45c4 chore: update CHANGELOG.md for version 0.11.1-alpha, highlighting secure sign-in improvements and update package version 2026-02-23 10:35:08 +01:00
43 changed files with 5593 additions and 128 deletions

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

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

1
.gitignore vendored
View File

@@ -37,5 +37,6 @@ next-env.d.ts
# PWA
public/sw.js
public/sw 2.js
public/workbox-*.js
public/swe-worker-*.js

View File

@@ -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

View File

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

View File

@@ -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
View 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>
)
}

View 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
View 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
View 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>
)
}

View File

@@ -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 })

View File

@@ -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

View File

@@ -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 && (

View File

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

View File

@@ -1,4 +1,4 @@
import ProfileSettings from '@/components/settings/ProfileSettings'
import SettingsPageClient from './SettingsPageClient'
export const metadata = {
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>
)
}

View File

@@ -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`

View File

@@ -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
/>
)
}

View File

@@ -0,0 +1,216 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '@/lib/auth/context'
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
import { Spinner } from '@ciphera-net/ui'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
const PAGE_SIZE = 20
const EVENT_LABELS: Record<string, string> = {
login_success: 'Sign in',
login_failure: 'Failed sign in',
oauth_login_success: 'OAuth sign in',
oauth_login_failure: 'Failed OAuth sign in',
password_change: 'Password changed',
'2fa_enabled': '2FA enabled',
'2fa_disabled': '2FA disabled',
recovery_codes_regenerated: 'Recovery codes regenerated',
account_deleted: 'Account deleted',
}
const EVENT_ICONS: Record<string, string> = {
login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
oauth_login_success: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
oauth_login_failure: 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
password_change: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
'2fa_enabled': 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z',
'2fa_disabled': 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z',
recovery_codes_regenerated: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
account_deleted: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
}
function getEventColor(eventType: string, outcome: string): string {
if (outcome === 'failure') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
if (eventType === '2fa_enabled') return 'text-green-500 dark:text-green-400 bg-green-50 dark:bg-green-950/30'
if (eventType === '2fa_disabled') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
if (eventType === 'account_deleted') return 'text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30'
if (eventType === 'recovery_codes_regenerated') return 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30'
return 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800'
}
function getMethodLabel(entry: AuditLogEntry): string | null {
const method = entry.metadata?.method
if (!method) return null
if (method === 'magic_link') return 'Magic link'
if (method === 'passkey') return 'Passkey'
return method as string
}
function getFailureReason(entry: AuditLogEntry): string | null {
if (entry.outcome !== 'failure') return null
const reason = entry.metadata?.reason
if (!reason) return null
const labels: Record<string, string> = {
invalid_credentials: 'Invalid credentials',
invalid_password: 'Wrong password',
account_locked: 'Account locked',
email_not_verified: 'Email not verified',
invalid_2fa: 'Invalid 2FA code',
}
return labels[reason as string] || (reason as string).replace(/_/g, ' ')
}
function parseBrowserName(ua: string): string {
if (!ua) return 'Unknown'
if (ua.includes('Firefox')) return 'Firefox'
if (ua.includes('Edg/')) return 'Edge'
if (ua.includes('Chrome')) return 'Chrome'
if (ua.includes('Safari')) return 'Safari'
if (ua.includes('Opera') || ua.includes('OPR')) return 'Opera'
return 'Browser'
}
function parseOS(ua: string): string {
if (!ua) return ''
if (ua.includes('Mac OS X')) return 'macOS'
if (ua.includes('Windows')) return 'Windows'
if (ua.includes('Linux')) return 'Linux'
if (ua.includes('Android')) return 'Android'
if (ua.includes('iPhone') || ua.includes('iPad')) return 'iOS'
return ''
}
export default function SecurityActivityCard() {
const { user } = useAuth()
const [entries, setEntries] = useState<AuditLogEntry[]>([])
const [totalCount, setTotalCount] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState('')
const [offset, setOffset] = useState(0)
const fetchActivity = useCallback(async (currentOffset: number, append: boolean) => {
try {
const data = await getUserActivity(PAGE_SIZE, currentOffset)
const newEntries = data.entries ?? []
setEntries(prev => append ? [...prev, ...newEntries] : newEntries)
setTotalCount(data.total_count)
setHasMore(data.has_more)
setOffset(currentOffset + newEntries.length)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load activity')
}
}, [])
useEffect(() => {
if (!user) return
setLoading(true)
fetchActivity(0, false).finally(() => setLoading(false))
}, [user, fetchActivity])
const handleLoadMore = async () => {
setLoadingMore(true)
await fetchActivity(offset, true)
setLoadingMore(false)
}
return (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Security Activity</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Recent security events on your account{totalCount > 0 ? ` (${totalCount})` : ''}
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No activity recorded yet.</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const label = EVENT_LABELS[entry.event_type] || entry.event_type.replace(/_/g, ' ')
const color = getEventColor(entry.event_type, entry.outcome)
const iconPath = EVENT_ICONS[entry.event_type] || EVENT_ICONS['login_success']
const method = getMethodLabel(entry)
const reason = getFailureReason(entry)
const browser = entry.user_agent ? parseBrowserName(entry.user_agent) : null
const os = entry.user_agent ? parseOS(entry.user_agent) : null
const deviceStr = [browser, os].filter(Boolean).join(' on ')
return (
<div
key={entry.id}
className="flex items-start gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className={`flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center mt-0.5 ${color}`}>
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-neutral-900 dark:text-white text-sm">
{label}
</span>
{method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
{method}
</span>
)}
{entry.outcome === 'failure' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-950/40 text-red-600 dark:text-red-400">
Failed
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 flex-wrap">
{reason && <span>{reason}</span>}
{reason && (deviceStr || entry.ip_address) && <span>&middot;</span>}
{deviceStr && <span>{deviceStr}</span>}
{deviceStr && entry.ip_address && <span>&middot;</span>}
{entry.ip_address && <span>{entry.ip_address}</span>}
</div>
</div>
<div className="flex-shrink-0 text-right">
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatFullDate(entry.created_at)}>
{formatRelativeTime(entry.created_at)}
</span>
</div>
</div>
)
})}
{hasMore && (
<div className="pt-2 text-center">
<button
type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className="text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors disabled:opacity-50"
>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useAuth } from '@/lib/auth/context'
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
import { Spinner, toast } from '@ciphera-net/ui'
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
function getDeviceIcon(hint: string): string {
const h = hint.toLowerCase()
if (h.includes('iphone') || h.includes('android') || h.includes('ios')) {
return 'M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3'
}
return 'M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z'
}
export default function TrustedDevicesCard() {
const { user } = useAuth()
const [devices, setDevices] = useState<TrustedDevice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [removingId, setRemovingId] = useState<string | null>(null)
const fetchDevices = useCallback(async () => {
try {
const data = await getUserDevices()
setDevices(data.devices ?? [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load devices')
}
}, [])
useEffect(() => {
if (!user) return
setLoading(true)
fetchDevices().finally(() => setLoading(false))
}, [user, fetchDevices])
const handleRemove = async (device: TrustedDevice) => {
if (device.is_current) {
toast.error('You cannot remove the device you are currently using.')
return
}
setRemovingId(device.id)
try {
await removeDevice(device.id)
setDevices(prev => prev.filter(d => d.id !== device.id))
toast.success('Device removed. A new sign-in from it will trigger an alert.')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to remove device')
} finally {
setRemovingId(null)
}
}
return (
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Trusted Devices</h2>
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">
Devices that have signed in to your account. Removing a device means the next sign-in from it will trigger a new device alert.
</p>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : error ? (
<div className="rounded-2xl border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20 p-6 text-center">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : devices.length === 0 ? (
<div className="rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z" />
</svg>
<p className="text-neutral-500 dark:text-neutral-400">No trusted devices yet. They appear after you sign in.</p>
</div>
) : (
<div className="space-y-2">
{devices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-3"
>
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={getDeviceIcon(device.display_hint)} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-neutral-900 dark:text-white text-sm truncate">
{device.display_hint || 'Unknown device'}
</span>
{device.is_current && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-950/40 text-green-600 dark:text-green-400 flex-shrink-0">
This device
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
<span title={formatFullDate(device.first_seen_at)}>
First seen {formatRelativeTime(device.first_seen_at)}
</span>
<span>&middot;</span>
<span title={formatFullDate(device.last_seen_at)}>
Last seen {formatRelativeTime(device.last_seen_at)}
</span>
</div>
</div>
{!device.is_current && (
<button
type="button"
onClick={() => handleRemove(device)}
disabled={removingId === device.id}
className="flex-shrink-0 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors disabled:opacity-50"
>
{removingId === device.id ? 'Removing...' : 'Remove'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -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
View File

@@ -0,0 +1,28 @@
import apiRequest from './client'
export interface AuditLogEntry {
id: string
created_at: string
event_type: string
outcome: string
ip_address?: string
user_agent?: string
metadata?: Record<string, string>
}
export interface ActivityResponse {
entries: AuditLogEntry[] | null
total_count: number
has_more: boolean
limit: number
offset: number
}
export async function getUserActivity(
limit = 20,
offset = 0
): Promise<ActivityResponse> {
return apiRequest<ActivityResponse>(
`/auth/user/activity?limit=${limit}&offset=${offset}`
)
}

62
lib/api/admin.ts Normal file
View 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),
})
}

View File

@@ -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
View File

@@ -0,0 +1,19 @@
import apiRequest from './client'
export interface TrustedDevice {
id: string
display_hint: string
first_seen_at: string
last_seen_at: string
is_current: boolean
}
export async function getUserDevices(): Promise<{ devices: TrustedDevice[] }> {
return apiRequest<{ devices: TrustedDevice[] }>('/auth/user/devices')
}
export async function removeDevice(deviceId: string): Promise<void> {
return apiRequest<void>(`/auth/user/devices/${deviceId}`, {
method: 'DELETE',
})
}

View File

@@ -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()}`)
}

View File

@@ -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
View 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' }
)
}

View File

@@ -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>
)

View File

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

View File

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

View File

@@ -0,0 +1,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
View 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 }

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,30 @@
export function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
const diffHr = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHr / 24)
if (diffMin < 1) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
})
}
export function formatFullDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}

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

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

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

1
vitest.setup.ts Normal file
View File

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