Performance insights, Goals & Events, 2FA improvements, auth fixes #36

Merged
uz1mani merged 12 commits from staging into main 2026-02-25 19:41:07 +00:00
10 changed files with 138 additions and 72 deletions

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { logger } from '@/lib/utils/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context'
import { AUTH_URL, default as apiRequest } from '@/lib/api/client'
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
import { exchangeAuthCode } from '@/app/actions/auth'
import { authMessageFromErrorType, type AuthErrorType } from '@ciphera-net/ui'
import { LoadingOverlay } from '@ciphera-net/ui'
@@ -21,7 +21,7 @@ function AuthCallbackContent() {
const code = searchParams.get('code')
const codeVerifier = localStorage.getItem('oauth_code_verifier')
const redirectUri = typeof window !== 'undefined' ? window.location.origin + '/auth/callback' : ''
if (!code || !codeVerifier) return
if (!code) return
const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
if (result.success && result.user) {
// * Fetch full profile (including display_name) before navigating so header shows correct name on first paint
@@ -47,59 +47,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

View File

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

View File

@@ -27,14 +27,16 @@ export async function verify2FA(code: string): Promise<Verify2FAResponse> {
})
}
export async function disable2FA(): Promise<void> {
export async function disable2FA(passwordDerived: string): Promise<void> {
return apiRequest<void>('/auth/2fa/disable', {
method: 'POST',
body: JSON.stringify({ password: passwordDerived }),
})
}
export async function regenerateRecoveryCodes(): Promise<RegenerateCodesResponse> {
export async function regenerateRecoveryCodes(passwordDerived: string): Promise<RegenerateCodesResponse> {
return apiRequest<RegenerateCodesResponse>('/auth/2fa/recovery', {
method: 'POST',
body: JSON.stringify({ password: passwordDerived }),
})
}

54
lib/api/webauthn.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* WebAuthn / Passkey API client for settings (list, register, delete).
*/
import { startRegistration, type PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
import apiRequest from './client'
export interface BeginRegistrationResponse {
sessionId: string
creationOptions: {
publicKey: Record<string, unknown>
mediation?: string
}
}
export interface PasskeyCredential {
id: string
createdAt: string
}
export interface ListPasskeysResponse {
credentials: PasskeyCredential[]
}
export async function registerPasskey(): Promise<void> {
const { sessionId, creationOptions } = await apiRequest<BeginRegistrationResponse>(
'/auth/webauthn/register/begin',
{ method: 'POST' }
)
const optionsJSON = creationOptions?.publicKey
if (!optionsJSON) {
throw new Error('Invalid registration options')
}
const response = await startRegistration({
optionsJSON: optionsJSON as unknown as PublicKeyCredentialCreationOptionsJSON,
})
await apiRequest<{ message: string }>('/auth/webauthn/register/finish', {
method: 'POST',
body: JSON.stringify({ sessionId, response }),
})
}
export async function listPasskeys(): Promise<ListPasskeysResponse> {
return apiRequest<ListPasskeysResponse>('/auth/webauthn/credentials', {
method: 'GET',
})
}
export async function deletePasskey(credentialId: string): Promise<void> {
return apiRequest<void>(
`/auth/webauthn/credentials/${encodeURIComponent(credentialId)}`,
{ method: 'DELETE' }
)
}

View File

@@ -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) {
greptile-apps[bot] commented 2026-02-25 19:43:19 +00:00 (Migrated from github.com)
Review

Silent refresh failure may hide errors

When the refresh request fails (non-ok response or network error), the code silently falls through to the else branch which sets user to null. This is functionally correct, but a fetch exception (e.g., network failure) will propagate as an unhandled error from the init function, which could cause the loading state to never be set to false, leaving the app in a permanent loading state.

Consider wrapping the refresh attempt in a try/catch:

        if (!session && typeof window !== 'undefined') {
          try {
            const refreshRes = await fetch('/api/auth/refresh', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              credentials: 'include',
            })
            if (refreshRes.ok) {
              session = await getSessionAction()
            }
          } catch {
            // Refresh failed (network error) — fall through to no-session handling
          }
        }
Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/auth/context.tsx
Line: 116-125

Comment:
**Silent refresh failure may hide errors**

When the refresh request fails (non-ok response or network error), the code silently falls through to the `else` branch which sets `user` to `null`. This is functionally correct, but a `fetch` exception (e.g., network failure) will propagate as an unhandled error from the `init` function, which could cause the `loading` state to never be set to `false`, leaving the app in a permanent loading state.

Consider wrapping the refresh attempt in a try/catch:

```suggestion
        if (!session && typeof window !== 'undefined') {
          try {
            const refreshRes = await fetch('/api/auth/refresh', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              credentials: 'include',
            })
            if (refreshRes.ok) {
              session = await getSessionAction()
            }
          } catch {
            // Refresh failed (network error) — fall through to no-session handling
          }
        }
```

How can I resolve this? If you propose a fix, please make it concise.
**Silent refresh failure may hide errors** When the refresh request fails (non-ok response or network error), the code silently falls through to the `else` branch which sets `user` to `null`. This is functionally correct, but a `fetch` exception (e.g., network failure) will propagate as an unhandled error from the `init` function, which could cause the `loading` state to never be set to `false`, leaving the app in a permanent loading state. Consider wrapping the refresh attempt in a try/catch: ```suggestion if (!session && typeof window !== 'undefined') { try { const refreshRes = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }) if (refreshRes.ok) { session = await getSessionAction() } } catch { // Refresh failed (network error) — fall through to no-session handling } } ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: lib/auth/context.tsx Line: 116-125 Comment: **Silent refresh failure may hide errors** When the refresh request fails (non-ok response or network error), the code silently falls through to the `else` branch which sets `user` to `null`. This is functionally correct, but a `fetch` exception (e.g., network failure) will propagate as an unhandled error from the `init` function, which could cause the `loading` state to never be set to `false`, leaving the app in a permanent loading state. Consider wrapping the refresh attempt in a try/catch: ```suggestion if (!session && typeof window !== 'undefined') { try { const refreshRes = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', }) if (refreshRes.ok) { session = await getSessionAction() } } catch { // Refresh failed (network error) — fall through to no-session handling } } ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
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')

View File

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

19
package-lock.json generated
View File

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

View File

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