From d14911baf9f45edb950eef63a2ecb4512401b5bc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 13:38:40 +0100 Subject: [PATCH 01/20] chore: update @ciphera-net/ui dependency to version 0.0.70 in package.json and package-lock.json --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 030a969..dc3648f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.69", + "@ciphera-net/ui": "^0.0.70", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.69", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.69/f4bdafba179e509c05209a984770b262bb1a8331", - "integrity": "sha512-ERx6Qs4A+igzNSN5FwkLqZlsnorh9wM9P9SrdyAeMAlf9Dxvwwjvu1vWM6NApEL4oVfRdSHswhdtrcK/PQIy0g==", + "version": "0.0.70", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.70/08792280d0d6b705471aa6070a8bf3e3a753e895", + "integrity": "sha512-XrHRJQscTDMEU4Bp3jSyQ7NLLvNv5FM4lOYuvuVQfuzFZSLDlQv30jFuuM2MVMDL0axU8odNMN07XkVjONp7Ww==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index 6cc163e..03abbbc 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.69", + "@ciphera-net/ui": "^0.0.70", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 67dcca660e440f0107801510b644ac7dc9d56109 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 13:51:24 +0100 Subject: [PATCH 02/20] chore: update @ciphera-net/ui dependency to version 0.0.71 in package.json and package-lock.json --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc3648f..61eddb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.70", + "@ciphera-net/ui": "^0.0.71", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.70", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.70/08792280d0d6b705471aa6070a8bf3e3a753e895", - "integrity": "sha512-XrHRJQscTDMEU4Bp3jSyQ7NLLvNv5FM4lOYuvuVQfuzFZSLDlQv30jFuuM2MVMDL0axU8odNMN07XkVjONp7Ww==", + "version": "0.0.71", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.71/6ec24ff340c036efb82e0417c72ec0652cc14d83", + "integrity": "sha512-kfvCXb27BKAy1wQC9epecnDU7lS/SQ7eMVG7OeTZlWmy0ZwOE09E2Q6XTk164xLdSfXRkofxAluVwsuMEY4gFg==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index 03abbbc..cd33eca 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.70", + "@ciphera-net/ui": "^0.0.71", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 3ff5ee4b6c34a4c45961b5e4180fba33f85dbe77 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 14:15:40 +0100 Subject: [PATCH 03/20] 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 --- CHANGELOG.md | 1 + lib/auth/context.tsx | 33 ++++++++++++++++++++++----------- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4adb0f..b5f0971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - **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. - **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. diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index a8b5810..bbef444 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -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 } 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' @@ -74,10 +74,12 @@ 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') - + // * 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) @@ -142,17 +144,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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') + window.location.href = '/' + }, + onLogin: (userData) => { + setUser(userData as User) + router.refresh() + }, + onRefresh: () => { + refresh() + }, + }) + // * Organization Wall & Auto-Switch useEffect(() => { const checkOrg = async () => { diff --git a/package-lock.json b/package-lock.json index 61eddb9..183acbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.71", + "@ciphera-net/ui": "^0.0.72", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.71", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.71/6ec24ff340c036efb82e0417c72ec0652cc14d83", - "integrity": "sha512-kfvCXb27BKAy1wQC9epecnDU7lS/SQ7eMVG7OeTZlWmy0ZwOE09E2Q6XTk164xLdSfXRkofxAluVwsuMEY4gFg==", + "version": "0.0.72", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.72/564070c6fadd0c1167f810df0b2fdf8b07718280", + "integrity": "sha512-bgDON7ZNY6ad0PWMY8ls+IGgP8dRY3c6LyQmWenp/1aTrbMPAU/jhpWXIOKsEU4ORzLKd0RbiJnHIG6SLqNiCA==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index cd33eca..d9259d5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.71", + "@ciphera-net/ui": "^0.0.72", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 8589842f16bd80e6ed349cc1f38942616c1a5f3d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 14:24:07 +0100 Subject: [PATCH 04/20] 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 --- CHANGELOG.md | 1 + lib/auth/context.tsx | 7 ++++++- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f0971..1bf51e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **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. diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index bbef444..1e64570 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -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, useSessionSync } 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' @@ -217,6 +217,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return ( {isLoggingOut && } + {children} ) diff --git a/package-lock.json b/package-lock.json index 183acbc..1ad1e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.72", + "@ciphera-net/ui": "^0.0.73", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.72", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.72/564070c6fadd0c1167f810df0b2fdf8b07718280", - "integrity": "sha512-bgDON7ZNY6ad0PWMY8ls+IGgP8dRY3c6LyQmWenp/1aTrbMPAU/jhpWXIOKsEU4ORzLKd0RbiJnHIG6SLqNiCA==", + "version": "0.0.73", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.73/6088fdf13bf44c27a90afaa491e7b858335ee086", + "integrity": "sha512-r7015AM2iGUWhRsSbjqMBZetXfKtj5ohIEJTV1weoyc1XX6jg7gOxCcAetuBKWaY1OqyJPhn+PCrE/xIE/dUyQ==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index d9259d5..e71090b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.72", + "@ciphera-net/ui": "^0.0.73", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From a928d2577b5e747f283dd2e19cb5c1d6dfb452b3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 15:03:44 +0100 Subject: [PATCH 05/20] 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 --- CHANGELOG.md | 4 ++++ package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf51e0..9a9bfd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events. - **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 + +- **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. diff --git a/package-lock.json b/package-lock.json index 1ad1e71..8c86ca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.73", + "@ciphera-net/ui": "^0.0.74", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.73", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.73/6088fdf13bf44c27a90afaa491e7b858335ee086", - "integrity": "sha512-r7015AM2iGUWhRsSbjqMBZetXfKtj5ohIEJTV1weoyc1XX6jg7gOxCcAetuBKWaY1OqyJPhn+PCrE/xIE/dUyQ==", + "version": "0.0.74", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.74/9537c860db93e7ce80a18bb699aa0c768cb13ec1", + "integrity": "sha512-Ha1uZ0AKKVBV4YBQRGXpWYlqc+rWH07gwEYdeLnVJvMRh/KlD2vrx95H1ldJf1Q1QfCP/HUcq55BelF/vvjzug==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index e71090b..7594ec1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.73", + "@ciphera-net/ui": "^0.0.74", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 22bc18a7cc88bcf1e91122b335a195b934196bca Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 27 Feb 2026 17:26:08 +0100 Subject: [PATCH 06/20] 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 --- CHANGELOG.md | 1 + lib/api/client.ts | 7 ++++ lib/utils/errorHandler.ts | 79 +++++++++++++++++++++++++++++++++++++++ lib/utils/requestId.ts | 43 +++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 lib/utils/errorHandler.ts create mode 100644 lib/utils/requestId.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9bfd0..4610d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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 diff --git a/lib/api/client.ts b/lib/api/client.ts index 389462f..13bd1cb 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -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 @@ -180,8 +182,13 @@ async function apiRequest( ? `${baseUrl}${endpoint}` : `${baseUrl}/api/v1${endpoint}` + // * Generate and store request ID for tracing + const requestId = generateRequestId() + setLastRequestId(requestId) + const headers: Record = { 'Content-Type': 'application/json', + [getRequestIdHeader()]: requestId, } // * Merge any additional headers from options diff --git a/lib/utils/errorHandler.ts b/lib/utils/errorHandler.ts new file mode 100644 index 0000000..ee4a15b --- /dev/null +++ b/lib/utils/errorHandler.ts @@ -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.' +} diff --git a/lib/utils/requestId.ts b/lib/utils/requestId.ts new file mode 100644 index 0000000..de6f3bf --- /dev/null +++ b/lib/utils/requestId.ts @@ -0,0 +1,43 @@ +/** + * Request ID utilities for tracing API calls across services + * Request IDs help debug issues by correlating logs across frontend and backends + */ + +const REQUEST_ID_HEADER = 'X-Request-ID' + +/** + * Generate a unique request ID + * Format: REQ_ + */ +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 + */ +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 +} From c4366808761d86b9e583f7cf44b284693965ce1b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 17:56:06 +0100 Subject: [PATCH 07/20] 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 --- app/settings/SettingsPageClient.tsx | 150 ++++++++++++++++++++++++ app/settings/page.tsx | 4 +- components/settings/ProfileSettings.tsx | 8 +- package.json | 2 +- 4 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 app/settings/SettingsPageClient.tsx diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx new file mode 100644 index 0000000..901d138 --- /dev/null +++ b/app/settings/SettingsPageClient.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState } from 'react' +import ProfileSettings from '@/components/settings/ProfileSettings' +import { motion, AnimatePresence } from 'framer-motion' +import { + UserIcon, + ChevronDownIcon, +} from '@ciphera-net/ui' + +type ProfileSubTab = 'profile' | 'security' | 'preferences' + +function SectionHeader({ + expanded, + active, + onToggle, + icon: Icon, + label, + description, +}: { + expanded: boolean + active: boolean + onToggle: () => void + icon: React.ElementType + label: string + description?: string +}) { + return ( + + ) +} + +function SubItem({ + active, + onClick, + label, +}: { + active: boolean + onClick: () => void + label: string +}) { + return ( + + ) +} + +function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) { + return ( + + {expanded && ( + +
+ {children} +
+
+ )} +
+ ) +} + +export default function SettingsPageClient() { + const [activeSubTab, setActiveSubTab] = useState('profile') + const [expanded, setExpanded] = useState(true) + + return ( +
+
+

Settings

+

+ Manage your account settings and preferences. +

+
+ +
+ {/* Sidebar Navigation */} + + + {/* Content Area */} +
+ +
+
+
+ ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f56e3d2..334828c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 (
- +
) } diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx index 56a91af..d98574b 100644 --- a/components/settings/ProfileSettings.tsx +++ b/components/settings/ProfileSettings.tsx @@ -8,7 +8,11 @@ import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, u 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 @@ -54,6 +58,8 @@ export default function ProfileSettings() { deriveAuthKey={deriveAuthKey} refreshUser={refresh} logout={logout} + activeTab={activeTab} + hideNav={activeTab !== undefined} /> ) } diff --git a/package.json b/package.json index 7594ec1..b96bcd0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.74", + "@ciphera-net/ui": "^0.0.75", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From fcd36dcaebedcaf9770ac01f22889a601714769f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 17:57:39 +0100 Subject: [PATCH 08/20] chore: update package-lock.json Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c86ca1..33e3059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.74", + "@ciphera-net/ui": "^0.0.75", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.74", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.74/9537c860db93e7ce80a18bb699aa0c768cb13ec1", - "integrity": "sha512-Ha1uZ0AKKVBV4YBQRGXpWYlqc+rWH07gwEYdeLnVJvMRh/KlD2vrx95H1ldJf1Q1QfCP/HUcq55BelF/vvjzug==", + "version": "0.0.75", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.75/0a5f40babb9c7a8ae9d067155bc7c05c322ca410", + "integrity": "sha512-t1dPS9sID1qCC8A3bz91dtJX9NwnfqaNhkWJpeLQQDPxg89+HodtoqQ2gZaLv2X0rK1gekh6MRBNwXil1ePxBA==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", From c4e95268fe4c50d804b17ee61d3b4ed13274aa61 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 19:13:09 +0100 Subject: [PATCH 09/20] feat: enhance settings page with account management and sidebar navigation --- app/settings/SettingsPageClient.tsx | 316 +++++++++++++++++++++++----- 1 file changed, 267 insertions(+), 49 deletions(-) diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx index 901d138..6279c07 100644 --- a/app/settings/SettingsPageClient.tsx +++ b/app/settings/SettingsPageClient.tsx @@ -1,15 +1,30 @@ 'use client' import { useState } from 'react' +import { useAuth } from '@/lib/auth/context' import ProfileSettings from '@/components/settings/ProfileSettings' import { motion, AnimatePresence } from 'framer-motion' import { UserIcon, + LockIcon, + BoxIcon, + ChevronRightIcon, ChevronDownIcon, + ExternalLinkIcon, } from '@ciphera-net/ui' +// --- Types --- + type ProfileSubTab = 'profile' | 'security' | 'preferences' +type ActiveSelection = + | { section: 'profile'; subTab: ProfileSubTab } + | { section: 'account' } + +type ExpandableSection = 'profile' | 'account' + +// --- Sidebar Components --- + function SectionHeader({ expanded, active, @@ -17,6 +32,7 @@ function SectionHeader({ icon: Icon, label, description, + hasChildren = true, }: { expanded: boolean active: boolean @@ -24,6 +40,7 @@ function SectionHeader({ icon: React.ElementType label: string description?: string + hasChildren?: boolean }) { return ( ) } @@ -56,10 +77,12 @@ function SubItem({ active, onClick, label, + external = false, }: { active: boolean onClick: () => void label: string + external?: boolean }) { return ( ) } @@ -95,56 +119,250 @@ function ExpandableSubItems({ expanded, children }: { expanded: boolean; childre ) } -export default function SettingsPageClient() { - const [activeSubTab, setActiveSubTab] = useState('profile') - const [expanded, setExpanded] = useState(true) +// --- Content Components --- + +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 ( -
-
-

Settings

-

- Manage your account settings and preferences. -

+
+
+
+ +
+
+

Ciphera Account

+

Manage your account across all Ciphera products

+
-
- {/* Sidebar Navigation */} - +
+ {accountLinks.map((link) => ( + + +
+
+ + {link.label} + + +
+

{link.description}

+
+ +
+ ))} +
- {/* Content Area */} -
- -
+
+

+ These settings apply to your Ciphera Account and affect all products (Drop, Pulse, and Auth). +

) } + +// --- Main Settings Section --- + +function AppSettingsSection() { + const [active, setActive] = useState({ section: 'profile', subTab: 'profile' }) + const [expanded, setExpanded] = useState>(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 + case 'account': + return + default: + return null + } + } + + return ( +
+ {/* Sidebar Navigation */} + + + {/* Content Area */} +
+ {renderContent()} +
+
+ ) +} + +export default function SettingsPageClient() { + const { user } = useAuth() + + return ( +
+ {/* Page Header */} +
+

Settings

+

+ Manage your Pulse preferences and Ciphera account settings +

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

Notification Preferences

+

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

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

Security Activity

+

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

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

{error}

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

No activity recorded yet.

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

Trusted Devices

+

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

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

{error}

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

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

+
+ ) : ( +
+ {devices.map((device) => ( +
+
+ + + +
+ +
+
+ + {device.display_hint || 'Unknown device'} + + {device.is_current && ( + + This device + + )} +
+
+ + First seen {formatRelativeTime(device.first_seen_at)} + + · + + Last seen {formatRelativeTime(device.last_seen_at)} + +
+
+ + {!device.is_current && ( + + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/lib/api/activity.ts b/lib/api/activity.ts new file mode 100644 index 0000000..894f45a --- /dev/null +++ b/lib/api/activity.ts @@ -0,0 +1,28 @@ +import apiRequest from './client' + +export interface AuditLogEntry { + id: string + created_at: string + event_type: string + outcome: string + ip_address?: string + user_agent?: string + metadata?: Record +} + +export interface ActivityResponse { + entries: AuditLogEntry[] | null + total_count: number + has_more: boolean + limit: number + offset: number +} + +export async function getUserActivity( + limit = 20, + offset = 0 +): Promise { + return apiRequest( + `/auth/user/activity?limit=${limit}&offset=${offset}` + ) +} diff --git a/lib/api/devices.ts b/lib/api/devices.ts new file mode 100644 index 0000000..501148f --- /dev/null +++ b/lib/api/devices.ts @@ -0,0 +1,19 @@ +import apiRequest from './client' + +export interface TrustedDevice { + id: string + display_hint: string + first_seen_at: string + last_seen_at: string + is_current: boolean +} + +export async function getUserDevices(): Promise<{ devices: TrustedDevice[] }> { + return apiRequest<{ devices: TrustedDevice[] }>('/auth/user/devices') +} + +export async function removeDevice(deviceId: string): Promise { + return apiRequest(`/auth/user/devices/${deviceId}`, { + method: 'DELETE', + }) +} From 15f82eee0027ec3099d1dc48cbf09797716162a3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 20:36:53 +0100 Subject: [PATCH 11/20] feat: add email notification preferences and update settings page structure --- app/settings/SettingsPageClient.tsx | 146 +++++++++++++++++++++--- components/settings/ProfileSettings.tsx | 1 + package-lock.json | 8 +- package.json | 2 +- 4 files changed, 136 insertions(+), 21 deletions(-) diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx index ee478c6..2269181 100644 --- a/app/settings/SettingsPageClient.tsx +++ b/app/settings/SettingsPageClient.tsx @@ -1,11 +1,12 @@ 'use client' -import { useState } from 'react' +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, @@ -29,15 +30,16 @@ function BellIcon({ className }: { className?: string }) { // --- Types --- type ProfileSubTab = 'profile' | 'security' | 'preferences' +type NotificationSubTab = 'email' | 'center' type ActiveSelection = | { section: 'profile'; subTab: ProfileSubTab } - | { section: 'notifications' } + | { section: 'notifications'; subTab: NotificationSubTab } | { section: 'account' } | { section: 'devices' } | { section: 'activity' } -type ExpandableSection = 'profile' | 'account' +type ExpandableSection = 'profile' | 'notifications' | 'account' // --- Sidebar Components --- @@ -137,6 +139,98 @@ function ExpandableSubItems({ expanded, children }: { expanded: boolean; childre // --- Content Components --- +// Email Notification Preferences Card +const PULSE_NOTIFICATION_OPTIONS = [ + { key: 'security_alerts', label: 'Security Alerts', description: 'Important security events like new logins, password changes, and 2FA updates.' }, +] + +function EmailNotificationPreferencesCard() { + const { user } = useAuth() + const [emailNotifications, setEmailNotifications] = useState>({}) + + useEffect(() => { + if (user?.preferences?.email_notifications) { + setEmailNotifications(user.preferences.email_notifications) + } else { + const defaults = PULSE_NOTIFICATION_OPTIONS.reduce((acc, option) => ({ + ...acc, + [option.key]: true + }), {} as Record) + 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; security_alerts: boolean } + }) + } catch { + setEmailNotifications(prev => ({ + ...prev, + [key]: !prev[key] + })) + } + } + + return ( +
+
+
+ +
+
+

Email Notifications

+

Choose which email notifications you receive

+
+
+ +
+ {PULSE_NOTIFICATION_OPTIONS.map((item) => ( +
+
+ + {item.label} + + + {item.description} + +
+ +
+ ))} +
+
+ ) +} + function AccountManagementCard() { const accountLinks = [ { @@ -234,13 +328,14 @@ function AppSettingsSection() { case 'profile': return case 'notifications': - return ( + if (active.subTab === 'email') return + if (active.subTab === 'center') return (
-

Notification Preferences

+

Notification Center

- Configure which notifications you receive and how you want to be notified. + View and manage all your notifications in one place.

) + return null case 'account': return case 'devices': @@ -306,16 +402,34 @@ function AppSettingsSection() {
- {/* Notifications (flat, no expansion) */} - setActive({ section: 'notifications' })} - icon={BellIcon} - label="Notifications" - description="Email and in-app notifications" - hasChildren={false} - /> + {/* Notifications (expandable) */} +
+ { + toggleSection('notifications') + if (!expanded.has('notifications')) { + selectSubTab({ section: 'notifications', subTab: 'email' }) + } + }} + icon={BellIcon} + label="Notifications" + description="Email and in-app notifications" + /> + + selectSubTab({ section: 'notifications', subTab: 'email' })} + label="Email Preferences" + /> + selectSubTab({ section: 'notifications', subTab: 'center' })} + label="Notification Center" + /> + +
diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx index d98574b..e1d4e5d 100644 --- a/components/settings/ProfileSettings.tsx +++ b/components/settings/ProfileSettings.tsx @@ -60,6 +60,7 @@ export default function ProfileSettings({ activeTab }: Props = {}) { logout={logout} activeTab={activeTab} hideNav={activeTab !== undefined} + hideNotifications /> ) } diff --git a/package-lock.json b/package-lock.json index 33e3059..87c5d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.75", + "@ciphera-net/ui": "^0.0.76", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.75", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.75/0a5f40babb9c7a8ae9d067155bc7c05c322ca410", - "integrity": "sha512-t1dPS9sID1qCC8A3bz91dtJX9NwnfqaNhkWJpeLQQDPxg89+HodtoqQ2gZaLv2X0rK1gekh6MRBNwXil1ePxBA==", + "version": "0.0.76", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.76/f584def8ea9ac4bccc52abdd281212f2e28959c0", + "integrity": "sha512-Bw7KOSUXQajfMAmgU39XiJGFudxx7Gj7Tnjr2tHHL+2oLOoBuDCNRRIC+A6Uo5WmxDBitw6voipoOfRcFo5XAA==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index b96bcd0..1184722 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.75", + "@ciphera-net/ui": "^0.0.76", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From 5ef6eafc633ee43dbae302e0119c252625f2c462 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 28 Feb 2026 21:18:57 +0100 Subject: [PATCH 12/20] feat: update notification preferences to include granular security alerts --- app/settings/SettingsPageClient.tsx | 32 +++++++++++++++-------------- lib/api/user.ts | 4 +++- lib/auth/context.tsx | 4 +++- package-lock.json | 8 ++++---- package.json | 2 +- 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx index 2269181..a98fa56 100644 --- a/app/settings/SettingsPageClient.tsx +++ b/app/settings/SettingsPageClient.tsx @@ -30,7 +30,7 @@ function BellIcon({ className }: { className?: string }) { // --- Types --- type ProfileSubTab = 'profile' | 'security' | 'preferences' -type NotificationSubTab = 'email' | 'center' +type NotificationSubTab = 'security' | 'center' type ActiveSelection = | { section: 'profile'; subTab: ProfileSubTab } @@ -139,12 +139,14 @@ function ExpandableSubItems({ expanded, children }: { expanded: boolean; childre // --- Content Components --- -// Email Notification Preferences Card -const PULSE_NOTIFICATION_OPTIONS = [ - { key: 'security_alerts', label: 'Security Alerts', description: 'Important security events like new logins, password changes, and 2FA updates.' }, +// 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 EmailNotificationPreferencesCard() { +function SecurityAlertsCard() { const { user } = useAuth() const [emailNotifications, setEmailNotifications] = useState>({}) @@ -152,7 +154,7 @@ function EmailNotificationPreferencesCard() { if (user?.preferences?.email_notifications) { setEmailNotifications(user.preferences.email_notifications) } else { - const defaults = PULSE_NOTIFICATION_OPTIONS.reduce((acc, option) => ({ + const defaults = SECURITY_ALERT_OPTIONS.reduce((acc, option) => ({ ...acc, [option.key]: true }), {} as Record) @@ -168,7 +170,7 @@ function EmailNotificationPreferencesCard() { setEmailNotifications(newState) try { await updateUserPreferences({ - email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; security_alerts: boolean } + email_notifications: newState as { new_file_received: boolean; file_downloaded: boolean; login_alerts: boolean; password_alerts: boolean; two_factor_alerts: boolean } }) } catch { setEmailNotifications(prev => ({ @@ -185,13 +187,13 @@ function EmailNotificationPreferencesCard() {
-

Email Notifications

-

Choose which email notifications you receive

+

Security Alerts

+

Choose which security events trigger email alerts

- {PULSE_NOTIFICATION_OPTIONS.map((item) => ( + {SECURITY_ALERT_OPTIONS.map((item) => (
case 'notifications': - if (active.subTab === 'email') return + if (active.subTab === 'security') return if (active.subTab === 'center') return (
@@ -410,7 +412,7 @@ function AppSettingsSection() { onToggle={() => { toggleSection('notifications') if (!expanded.has('notifications')) { - selectSubTab({ section: 'notifications', subTab: 'email' }) + selectSubTab({ section: 'notifications', subTab: 'security' }) } }} icon={BellIcon} @@ -419,9 +421,9 @@ function AppSettingsSection() { /> selectSubTab({ section: 'notifications', subTab: 'email' })} - label="Email Preferences" + active={active.section === 'notifications' && active.subTab === 'security'} + onClick={() => selectSubTab({ section: 'notifications', subTab: 'security' })} + label="Security Alerts" /> Date: Sat, 28 Feb 2026 23:02:43 +0100 Subject: [PATCH 13/20] feat: implement refresh token functionality and update local storage management --- lib/auth/context.tsx | 21 ++++++++++++++++++++- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 14591e4..7b1920a 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -51,9 +51,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const router = useRouter() const pathname = usePathname() + const refreshToken = useCallback(async (): Promise => { + 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 @@ -76,6 +92,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setIsLoggingOut(true) await logoutAction() localStorage.removeItem('user') + 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') @@ -131,6 +149,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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('/auth/user/me') @@ -221,7 +240,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { {isLoggingOut && } {children} diff --git a/package-lock.json b/package-lock.json index 7cbb03b..221d1fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.77", + "@ciphera-net/ui": "^0.0.78", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", @@ -1543,9 +1543,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.77", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.77/c243fce3b29ee4b3d90dd5b6b5a7d93ff3a955b9", - "integrity": "sha512-Z+aLef5843t9TIvaSV+ecC4sQi2VzX+hE6/7A/4/Y49CT91vg0x9QuQuQiV7B4j93Ui1yv+aZyWN21NY3mhKbA==", + "version": "0.0.78", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.78/d012b211ddba7a83f7468ec269a842709a2409b9", + "integrity": "sha512-c9B/cZggjWnCSpICEvRBAAPgsRfjN2j3NFczQRZxRR6sZmzkwd3KzEbDfTEa2DUExBMWB4+bOgiCz+AnP2OR3g==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", diff --git a/package.json b/package.json index 34c639f..b661b14 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.77", + "@ciphera-net/ui": "^0.0.78", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", "@simplewebauthn/browser": "^13.2.2", From b5f83ce582f3855913aac33bdcdacde46aff492a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 00:11:54 +0100 Subject: [PATCH 14/20] feat: add unit tests and CI configuration --- .github/workflows/test.yml | 27 + CHANGELOG.md | 1 + __tests__/middleware.test.ts | 99 + lib/hooks/__tests__/useOnlineStatus.test.ts | 34 + .../__tests__/useVisibilityPolling.test.ts | 99 + lib/utils/__tests__/errorHandler.test.ts | 95 + lib/utils/__tests__/logger.test.ts | 29 + lib/utils/__tests__/requestId.test.ts | 61 + package-lock.json | 2326 ++++++++++++++++- package.json | 11 +- vitest.config.ts | 18 + vitest.setup.ts | 1 + 12 files changed, 2798 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 __tests__/middleware.test.ts create mode 100644 lib/hooks/__tests__/useOnlineStatus.test.ts create mode 100644 lib/hooks/__tests__/useVisibilityPolling.test.ts create mode 100644 lib/utils/__tests__/errorHandler.test.ts create mode 100644 lib/utils/__tests__/logger.test.ts create mode 100644 lib/utils/__tests__/requestId.test.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2ea5ad9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +# * Runs unit tests on push/PR to main and staging. +name: Test + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + +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 + + - name: Run tests + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 4610d2b..df3b96c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. diff --git a/__tests__/middleware.test.ts b/__tests__/middleware.test.ts new file mode 100644 index 0000000..a15c1d2 --- /dev/null +++ b/__tests__/middleware.test.ts @@ -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 = {}): 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() + }) + }) +}) diff --git a/lib/hooks/__tests__/useOnlineStatus.test.ts b/lib/hooks/__tests__/useOnlineStatus.test.ts new file mode 100644 index 0000000..2185bbf --- /dev/null +++ b/lib/hooks/__tests__/useOnlineStatus.test.ts @@ -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) + }) +}) diff --git a/lib/hooks/__tests__/useVisibilityPolling.test.ts b/lib/hooks/__tests__/useVisibilityPolling.test.ts new file mode 100644 index 0000000..b96dfa3 --- /dev/null +++ b/lib/hooks/__tests__/useVisibilityPolling.test.ts @@ -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() + }) +}) diff --git a/lib/utils/__tests__/errorHandler.test.ts b/lib/utils/__tests__/errorHandler.test.ts new file mode 100644 index 0000000..0e5e4f6 --- /dev/null +++ b/lib/utils/__tests__/errorHandler.test.ts @@ -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.') + }) +}) diff --git a/lib/utils/__tests__/logger.test.ts b/lib/utils/__tests__/logger.test.ts new file mode 100644 index 0000000..4092a63 --- /dev/null +++ b/lib/utils/__tests__/logger.test.ts @@ -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() + }) +}) diff --git a/lib/utils/__tests__/requestId.test.ts b/lib/utils/__tests__/requestId.test.ts new file mode 100644 index 0000000..ce04a45 --- /dev/null +++ b/lib/utils/__tests__/requestId.test.ts @@ -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() + }) +}) diff --git a/package-lock.json b/package-lock.json index 221d1fe..7785fea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,19 +36,38 @@ }, "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" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -62,6 +81,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1196,6 +1273,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", @@ -1542,6 +1651,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@ciphera-net/ui": { "version": "0.0.78", "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.78/d012b211ddba7a83f7468ec269a842709a2409b9", @@ -1558,6 +1680,140 @@ "react-dom": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ducanh2912/next-pwa": { "version": "10.2.9", "resolved": "https://registry.npmjs.org/@ducanh2912/next-pwa/-/next-pwa-10.2.9.tgz", @@ -1609,6 +1865,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1753,6 +2451,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2567,6 +3283,13 @@ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -2712,6 +3435,356 @@ "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2725,6 +3798,13 @@ "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@stripe/react-stripe-js": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz", @@ -2783,6 +3863,91 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2794,6 +3959,70 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2895,6 +4124,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3019,6 +4255,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3612,6 +4849,168 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -3814,6 +5213,16 @@ "node": ">=0.8" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3870,6 +5279,16 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4089,6 +5508,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4292,6 +5721,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4497,6 +5936,16 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4775,6 +6224,27 @@ "utrie": "^1.0.2" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4788,6 +6258,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5022,6 +6518,58 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5090,6 +6638,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -5233,6 +6788,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -5308,6 +6870,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5485,6 +7060,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5987,6 +7604,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6680,6 +8307,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", @@ -6710,6 +8350,34 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18n-iso-countries": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", @@ -6765,6 +8433,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -7155,6 +8833,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7424,6 +9109,86 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7703,6 +9468,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -7874,6 +9649,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8365,6 +10147,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8723,6 +10515,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8843,6 +10646,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8878,6 +10694,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -9132,6 +10955,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9273,6 +11131,16 @@ "react": ">=18" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-simple-maps": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", @@ -9382,6 +11250,20 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9697,6 +11579,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9977,6 +11872,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/smob": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", @@ -10066,6 +11968,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -10076,6 +11985,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10245,6 +12161,19 @@ "node": ">=10" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10370,6 +12299,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -10570,6 +12506,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10619,6 +12572,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10651,6 +12634,19 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -10888,6 +12884,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11230,6 +13236,280 @@ "node": ">=12" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", @@ -11328,6 +13608,16 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -11440,6 +13730,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -11869,6 +14176,23 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b661b14..9ad4fcb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "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.78", @@ -44,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" } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d477984 --- /dev/null +++ b/vitest.config.ts @@ -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, '.'), + }, + }, +}) diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' From 9a39745323a3fe80ec6b3eb5c34ee035a5a35a53 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 00:13:57 +0100 Subject: [PATCH 15/20] feat: add NODE_AUTH_TOKEN environment variable for dependency installation --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ea5ad9..1166055 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,8 @@ jobs: - name: Install dependencies run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run tests run: npm test From 6bb356697b7a5c2789692529acaa2304e0dd5c7d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 00:20:17 +0100 Subject: [PATCH 16/20] feat: update test workflow to use PKG_READ_TOKEN for NODE_AUTH_TOKEN --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1166055..ab3c44a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,10 @@ on: pull_request: branches: [main, staging] +permissions: + contents: read + packages: read + jobs: test: name: unit-tests @@ -23,7 +27,7 @@ jobs: - name: Install dependencies run: npm ci env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.PKG_READ_TOKEN }} - name: Run tests run: npm test From 805617a29064552d0935cc49b67d1cbcb357e3a1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 00:29:57 +0100 Subject: [PATCH 17/20] chore: update version to 0.12.0-alpha and document automated testing in changelog --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df3b96c..c58148e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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. @@ -217,7 +219,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), --- -[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.12.0-alpha...HEAD +[0.12.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...v0.12.0-alpha [0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha [0.11.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 diff --git a/package-lock.json b/package-lock.json index 7785fea..7e8f495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-frontend", - "version": "0.11.1-alpha", + "version": "0.12.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.11.1-alpha", + "version": "0.12.0-alpha", "dependencies": { "@ciphera-net/ui": "^0.0.78", "@ducanh2912/next-pwa": "^10.2.9", diff --git a/package.json b/package.json index 9ad4fcb..85611e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.11.1-alpha", + "version": "0.12.0-alpha", "private": true, "scripts": { "dev": "next dev", From ac1ed581273ab20f03334f5c0dd09f44f176ec36 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 13:45:00 +0100 Subject: [PATCH 18/20] fix: improve reliability of background processing across multiple Pulse servers --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58148e..d3e58fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- **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. + ## [0.12.0-alpha] - 2026-03-01 ### Added From b3a303d6df22b30ecc04ac89c3e7a0db6be53d86 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 13:53:54 +0100 Subject: [PATCH 19/20] fix: improve session management and UI highlights --- CHANGELOG.md | 2 ++ app/settings/SettingsPageClient.tsx | 2 +- components/settings/SecurityActivityCard.tsx | 32 +------------------- components/settings/TrustedDevicesCard.tsx | 32 +------------------- lib/auth/context.tsx | 2 ++ lib/utils/formatDate.ts | 30 ++++++++++++++++++ lib/utils/requestId.ts | 8 ++++- 7 files changed, 44 insertions(+), 64 deletions(-) create mode 100644 lib/utils/formatDate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e58fb..673cf89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - **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. ## [0.12.0-alpha] - 2026-03-01 diff --git a/app/settings/SettingsPageClient.tsx b/app/settings/SettingsPageClient.tsx index a98fa56..5cb7464 100644 --- a/app/settings/SettingsPageClient.tsx +++ b/app/settings/SettingsPageClient.tsx @@ -443,7 +443,7 @@ function AppSettingsSection() {
{ toggleSection('account') if (!expanded.has('account')) { diff --git a/components/settings/SecurityActivityCard.tsx b/components/settings/SecurityActivityCard.tsx index 47b1ba9..7e5ca6b 100644 --- a/components/settings/SecurityActivityCard.tsx +++ b/components/settings/SecurityActivityCard.tsx @@ -4,6 +4,7 @@ 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 @@ -62,37 +63,6 @@ function getFailureReason(entry: AuditLogEntry): string | null { return labels[reason as string] || (reason as string).replace(/_/g, ' ') } -function formatRelativeTime(dateStr: string): string { - const date = new Date(dateStr) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMin = Math.floor(diffMs / 60000) - const diffHr = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHr / 24) - - if (diffMin < 1) return 'Just now' - if (diffMin < 60) return `${diffMin}m ago` - if (diffHr < 24) return `${diffHr}h ago` - if (diffDay < 7) return `${diffDay}d ago` - - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, - }) -} - -function formatFullDate(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) -} - function parseBrowserName(ua: string): string { if (!ua) return 'Unknown' if (ua.includes('Firefox')) return 'Firefox' diff --git a/components/settings/TrustedDevicesCard.tsx b/components/settings/TrustedDevicesCard.tsx index 21343c4..9441721 100644 --- a/components/settings/TrustedDevicesCard.tsx +++ b/components/settings/TrustedDevicesCard.tsx @@ -4,37 +4,7 @@ import { useEffect, useState, useCallback } from 'react' import { useAuth } from '@/lib/auth/context' import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices' import { Spinner, toast } from '@ciphera-net/ui' - -function formatRelativeTime(dateStr: string): string { - const date = new Date(dateStr) - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMin = Math.floor(diffMs / 60000) - const diffHr = Math.floor(diffMin / 60) - const diffDay = Math.floor(diffHr / 24) - - if (diffMin < 1) return 'Just now' - if (diffMin < 60) return `${diffMin}m ago` - if (diffHr < 24) return `${diffHr}h ago` - if (diffDay < 7) return `${diffDay}d ago` - - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, - }) -} - -function formatFullDate(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) -} +import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate' function getDeviceIcon(hint: string): string { const h = hint.toLowerCase() diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 7b1920a..1ebc65d 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -174,6 +174,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useSessionSync({ onLogout: () => { localStorage.removeItem('user') + localStorage.removeItem('ciphera_token_refreshed_at') + localStorage.removeItem('ciphera_last_activity') window.location.href = '/' }, onLogin: (userData) => { diff --git a/lib/utils/formatDate.ts b/lib/utils/formatDate.ts new file mode 100644 index 0000000..aa2cbaa --- /dev/null +++ b/lib/utils/formatDate.ts @@ -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', + }) +} diff --git a/lib/utils/requestId.ts b/lib/utils/requestId.ts index de6f3bf..1251fd8 100644 --- a/lib/utils/requestId.ts +++ b/lib/utils/requestId.ts @@ -1,6 +1,11 @@ /** * 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' @@ -23,7 +28,8 @@ export function getRequestIdHeader(): string { } /** - * Store the last request ID for error reporting + * Store the last request ID for error reporting. + * Browser-only — single-threaded, no concurrency risk. */ let lastRequestId: string | null = null From 29e84e3a4ff9826dcf319b18014f7e51c4e78749 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 14:02:31 +0100 Subject: [PATCH 20/20] fix: remove outdated fixes from changelog for clarity --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 673cf89..e82e3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -### Fixed - -- **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. - ## [0.12.0-alpha] - 2026-03-01 ### Added @@ -56,6 +50,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days. - **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