diff --git a/CHANGELOG.md b/CHANGELOG.md index 06558e7..5e1f285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Performance insights.** Track how fast your site loads with Core Web Vitals (page load speed, layout shifts, responsiveness). Turn it on in Site Settings → Data & Privacy to see a performance widget on your dashboard. +- **Goals & Events.** Define custom goals (e.g. signup, purchase) and track them with `pulse.track()` in your snippet. Counts appear on your dashboard once you add goals in Site Settings → Goals & Events. +- **2FA recovery codes backup.** When you enable 2FA, you receive recovery codes. You can now regenerate new codes (with password confirmation) from Settings and download them as a `.txt` file. Regenerating invalidates all existing codes. + +### Fixed + +- **Sign in after inactivity.** Clicking "Sign in" after a period of inactivity no longer does nothing. Previously, stale refresh cookies caused the middleware to redirect away from the login page; now only a valid access token triggers that redirect, so you can complete OAuth sign-in when your session has expired. +- **Frequent re-login.** You no longer have to sign in multiple times a day. When the access token expires after 15 minutes of inactivity, the app now automatically refreshes it using your refresh token on the next page load, so you stay logged in for up to 30 days. +- **2FA disable now requires password confirmation.** Disabling 2FA sends the derived password to the backend for verification. This prevents an attacker with a hijacked session from stripping 2FA. + +## [0.11.1-alpha] - 2026-02-23 + +### Changed + +- **Safer sign-in from the Ciphera hub.** When you open Pulse from the Ciphera Apps page, your credentials are no longer visible in the browser address bar. Sign-in now uses a secure one-time code that expires in seconds, so your session stays private even if someone sees your screen or browser history. + ## [0.11.0-alpha] - 2026-02-22 ### Added @@ -18,10 +36,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes. - **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology. - **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads. -- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down. -- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and reducing query latency. -- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) instead of silently returning empty or oversized results. -- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing. +- **Graceful server shutdown.** Deployments no longer kill in-flight requests or interrupt background tasks. The server finishes ongoing work before shutting down, so your active sessions aren't cut off mid-action. +- **Database connection pooling.** The backend now limits and recycles database connections, preventing exhaustion under load and keeping queries fast even with many concurrent users. +- **Date range validation.** Analytics, funnel, and uptime queries now reject invalid date ranges (end before start, or spans longer than a year) and show a clear error instead of empty or confusing results. +- **Excluded paths limit.** Sites can now have up to 50 excluded paths. Previously there was no cap, which could slow down event processing; the limit keeps things fast while still giving you flexibility. ### Changed @@ -29,8 +47,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data". - **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading. - **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI. -- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time. -- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle. +- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time and fewer edge cases slip through. +- **Smaller page downloads.** Icon imports are now tree-shaken so only the icons actually used are included in the bundle, reducing download size and speeding up page loads. - **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production. Error logs are now also suppressed in production and only appear during development. ### Fixed @@ -168,7 +186,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.0-alpha...HEAD +[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.11.1-alpha...HEAD +[0.11.1-alpha]: https://github.com/ciphera-net/pulse/compare/v0.11.0-alpha...v0.11.1-alpha [0.11.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.10.0-alpha...v0.11.0-alpha [0.10.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.9.0-alpha...v0.10.0-alpha [0.9.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.8.0-alpha...v0.9.0-alpha diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 869cfff..dc38c5f 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -33,7 +33,7 @@ interface UserPayload { /** Error type returned to client for mapping to user-facing copy (no sensitive details). */ export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server' -export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { +export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) { try { const res = await fetch(`${AUTH_API_URL}/oauth/token`, { method: 'POST', @@ -45,7 +45,7 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir code, client_id: 'pulse-app', redirect_uri: redirectUri, - code_verifier: codeVerifier, + code_verifier: codeVerifier || '', }), }) diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index b2a8c34..1e69707 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { AUTH_URL, default as apiRequest } from '@/lib/api/client' -import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' +import { exchangeAuthCode } from '@/app/actions/auth' import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui' import { LoadingOverlay } from '@ciphera-net/ui' @@ -21,7 +21,7 @@ function AuthCallbackContent() { const code = searchParams.get('code') const codeVerifier = localStorage.getItem('oauth_code_verifier') const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : '' - if (!code || !codeVerifier) return + if (!code) return const result = await exchangeAuthCode(code, codeVerifier, redirectUri) if (result.success && result.user) { // * Fetch full profile (including display_name) before navigating so header shows correct name on first paint @@ -47,59 +47,25 @@ function AuthCallbackContent() { }, [searchParams, login, router]) useEffect(() => { - // * Prevent double execution (React Strict Mode or fast re-renders) if (processedRef.current && !isRetrying) return - // * Check for direct token passing (from auth-frontend direct login) - // * This flow exposes tokens in URL, kept for legacy support. - // * Recommended: Use Authorization Code flow (below) - const token = searchParams.get('token') - const refreshToken = searchParams.get('refresh_token') - - if (token && refreshToken) { - processedRef.current = true - const handleDirectTokens = async () => { - const result = await setSessionAction(token, refreshToken) - if (result.success && result.user) { - // * Fetch full profile (including display_name) before navigating so header shows correct name on first paint - try { - const fullProfile = await apiRequest<{ id: string; email: string; display_name?: string; totp_enabled: boolean; org_id?: string; role?: string }>('/auth/user/me') - const merged = { ...fullProfile, org_id: result.user.org_id ?? fullProfile.org_id, role: result.user.role ?? fullProfile.role } - login(merged) - } catch { - login(result.user) - } - if (typeof window !== 'undefined' && localStorage.getItem('pulse_pending_checkout')) { - router.push('/welcome') - } else { - const raw = searchParams.get('returnTo') || '/' - const safe = (typeof raw === 'string' && raw.startsWith('/') && !raw.startsWith('//')) ? raw : '/' - router.push(safe) - } - } else { - setError(authMessageFromErrorType('invalid')) - } - } - handleDirectTokens() - return - } - const code = searchParams.get('code') + if (!code) return + const state = searchParams.get('state') - - if (!code || !state) return - const storedState = localStorage.getItem('oauth_state') const codeVerifier = localStorage.getItem('oauth_code_verifier') - if (!codeVerifier) { - setError('Missing code verifier') - return - } - if (state !== storedState) { - logger.error('State mismatch', { received: state, stored: storedState }) - setError('Invalid state') - return + // * Full OAuth flow (app-initiated): validate state + use PKCE + // * Session-authorized flow (from auth hub): no stored state or verifier + const isFullOAuth = !!storedState && !!codeVerifier + + if (isFullOAuth) { + if (state !== storedState) { + logger.error('State mismatch', { received: state, stored: storedState }) + setError('Invalid state') + return + } } processedRef.current = true diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx index 6713140..56a91af 100644 --- a/components/settings/ProfileSettings.tsx +++ b/components/settings/ProfileSettings.tsx @@ -6,6 +6,7 @@ import api from '@/lib/api/client' import { deriveAuthKey } from '@/lib/crypto/password' import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, updateDisplayName } from '@/lib/api/user' import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa' +import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn' export default function ProfileSettings() { const { user, refresh, logout } = useAuth() @@ -46,6 +47,9 @@ export default function ProfileSettings() { onRegenerateRecoveryCodes={regenerateRecoveryCodes} onGetSessions={getUserSessions} onRevokeSession={revokeSession} + onRegisterPasskey={registerPasskey} + onListPasskeys={listPasskeys} + onDeletePasskey={deletePasskey} onUpdatePreferences={updateUserPreferences} deriveAuthKey={deriveAuthKey} refreshUser={refresh} diff --git a/lib/api/2fa.ts b/lib/api/2fa.ts index e2a7570..b8247d4 100644 --- a/lib/api/2fa.ts +++ b/lib/api/2fa.ts @@ -27,14 +27,16 @@ export async function verify2FA(code: string): Promise { }) } -export async function disable2FA(): Promise { +export async function disable2FA(passwordDerived: string): Promise { return apiRequest('/auth/2fa/disable', { method: 'POST', + body: JSON.stringify({ password: passwordDerived }), }) } -export async function regenerateRecoveryCodes(): Promise { +export async function regenerateRecoveryCodes(passwordDerived: string): Promise { return apiRequest('/auth/2fa/recovery', { method: 'POST', + body: JSON.stringify({ password: passwordDerived }), }) } diff --git a/lib/api/webauthn.ts b/lib/api/webauthn.ts new file mode 100644 index 0000000..e3f2fe8 --- /dev/null +++ b/lib/api/webauthn.ts @@ -0,0 +1,54 @@ +/** + * WebAuthn / Passkey API client for settings (list, register, delete). + */ + +import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser' +import apiRequest from './client' + +export interface BeginRegistrationResponse { + sessionId: string + creationOptions: { + publicKey: Record + mediation?: string + } +} + +export interface PasskeyCredential { + id: string + createdAt: string +} + +export interface ListPasskeysResponse { + credentials: PasskeyCredential[] +} + +export async function registerPasskey(): Promise { + const { sessionId, creationOptions } = await apiRequest( + '/auth/webauthn/register/begin', + { method: 'POST' } + ) + const optionsJSON = creationOptions?.publicKey + if (!optionsJSON) { + throw new Error('Invalid registration options') + } + const response = await startRegistration({ + optionsJSON: optionsJSON as unknown as PublicKeyCredentialCreationOptionsJSON, + }) + await apiRequest<{ message: string }>('/auth/webauthn/register/finish', { + method: 'POST', + body: JSON.stringify({ sessionId, response }), + }) +} + +export async function listPasskeys(): Promise { + return apiRequest('/auth/webauthn/credentials', { + method: 'GET', + }) +} + +export async function deletePasskey(credentialId: string): Promise { + return apiRequest( + `/auth/webauthn/credentials/${encodeURIComponent(credentialId)}`, + { method: 'DELETE' } + ) +} diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 537732b..a8b5810 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -110,8 +110,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const init = async () => { // * 1. Check server-side session (cookies) - const session = await getSessionAction() - + let session = await getSessionAction() + + // * 2. If no access_token but refresh_token may exist, try refresh (fixes 15-min inactivity logout) + if (!session && typeof window !== 'undefined') { + const refreshRes = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }) + if (refreshRes.ok) { + session = await getSessionAction() + } + } + if (session) { setUser(session) localStorage.setItem('user', JSON.stringify(session)) @@ -129,7 +141,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.removeItem('user') setUser(null) } - + // * Clear legacy tokens if they exist (migration) if (localStorage.getItem('token')) { localStorage.removeItem('token') diff --git a/middleware.ts b/middleware.ts index 440ccf1..9ab1a7c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -34,8 +34,9 @@ export function middleware(request: NextRequest) { const hasRefresh = request.cookies.has('refresh_token') const hasSession = hasAccess || hasRefresh - // * Authenticated user hitting /login or /signup → send them home - if (hasSession && AUTH_ONLY_ROUTES.has(pathname)) { + // * Authenticated user (with access token) hitting /login or /signup → send them home. + // * Only check access_token; stale refresh_token alone must not block login (fixes post-inactivity sign-in). + if (hasAccess && AUTH_ONLY_ROUTES.has(pathname)) { return NextResponse.redirect(new URL('/', request.url)) } diff --git a/package-lock.json b/package-lock.json index eb8f7d6..3ca446f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "pulse-frontend", - "version": "0.9.0-alpha", + "version": "0.11.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-frontend", - "version": "0.9.0-alpha", + "version": "0.11.1-alpha", "dependencies": { - "@ciphera-net/ui": "^0.0.58", + "@ciphera-net/ui": "^0.0.64", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", + "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "axios": "^1.13.2", @@ -1541,9 +1542,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.58", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.58/ac48a989da2db79880ce2fa7f89b63a62e2b68c9", - "integrity": "sha512-cvptYjs+E72EQvM5YGx5pp4SOiyJ7t5qv5NSRfoFxtcTCwR4sKUN4SoZUA+HV3tLlq4qXXHAB98E7qgbBRIn+Q==", + "version": "0.0.64", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.64/1630605518a705ba9e74f003b9c66646bcc699ac", + "integrity": "sha512-xY+yALuCqWtsH78t6xmy2JQnQ8WFSlihElHnetFr6GQp9mOTCA5rlQq+a8hyg4xW7uXtIbKmPBxVnH5TlH9lBQ==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", @@ -2717,6 +2718,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", + "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", + "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", diff --git a/package.json b/package.json index 022fa33..f8d66ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-frontend", - "version": "0.11.0-alpha", + "version": "0.11.1-alpha", "private": true, "scripts": { "dev": "next dev", @@ -10,9 +10,10 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.58", + "@ciphera-net/ui": "^0.0.64", "@ducanh2912/next-pwa": "^10.2.9", "@radix-ui/react-icons": "^1.3.0", + "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "axios": "^1.13.2",