Merge branch 'main' into staging
This commit is contained in:
@@ -35,11 +35,15 @@ export type AuthExchangeErrorType = 'network' | 'expired' | 'invalid' | 'server'
|
||||
|
||||
export async function exchangeAuthCode(code: string, codeVerifier: string | null, redirectUri: string) {
|
||||
try {
|
||||
// * IMPORTANT: credentials: 'include' is required to receive httpOnly cookies from Auth API
|
||||
// * The Auth API sets access_token, refresh_token, and csrf_token as httpOnly cookies
|
||||
// * We must forward these to the browser for cross-subdomain auth to work
|
||||
const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // * Critical: receives httpOnly cookies from Auth API
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
@@ -91,6 +95,50 @@ export async function exchangeAuthCode(code: string, codeVerifier: string | null
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
// * Forward cookies from Auth API response to browser
|
||||
// * The Auth API sets httpOnly cookies on auth.ciphera.net - we need to mirror them on pulse.ciphera.net
|
||||
const setCookieHeaders = res.headers.getSetCookie()
|
||||
if (setCookieHeaders && setCookieHeaders.length > 0) {
|
||||
for (const cookieStr of setCookieHeaders) {
|
||||
// * Parse Set-Cookie header (format: name=value; attributes...)
|
||||
const [nameValue] = cookieStr.split(';')
|
||||
const [name, value] = nameValue.trim().split('=')
|
||||
|
||||
if (name && value) {
|
||||
// * Determine if httpOnly (default true for security)
|
||||
const isHttpOnly = cookieStr.toLowerCase().includes('httponly')
|
||||
// * Determine sameSite (default lax)
|
||||
const sameSiteMatch = cookieStr.match(/samesite=(\w+)/i)
|
||||
const sameSite = (sameSiteMatch?.[1]?.toLowerCase() as 'strict' | 'lax' | 'none') || 'lax'
|
||||
// * Extract max-age if present
|
||||
const maxAgeMatch = cookieStr.match(/max-age=(\d+)/i)
|
||||
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 60 * 60 * 24 * 30
|
||||
|
||||
cookieStore.set(name.trim(), decodeURIComponent(value.trim()), {
|
||||
httpOnly: isHttpOnly,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: sameSite,
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: maxAge
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Also check for CSRF token in response header (fallback)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
if (csrfToken && !cookieStore.get('csrf_token')) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function POST() {
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// * Get CSRF token from Auth API response header (for cookie rotation)
|
||||
const csrfToken = res.headers.get('X-CSRF-Token')
|
||||
|
||||
cookieStore.set('access_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
@@ -55,6 +58,18 @@ export async function POST() {
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
|
||||
// * Set/update CSRF token cookie (non-httpOnly, for JS access)
|
||||
if (csrfToken) {
|
||||
cookieStore.set('csrf_token', csrfToken, {
|
||||
httpOnly: false, // * Must be readable by JS for CSRF protection
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
domain: cookieDomain,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, access_token: data.access_token })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@ciphera-net/ui'
|
||||
import { Header, type CipheraApp } from '@ciphera-net/ui'
|
||||
import NotificationCenter from '@/components/notifications/NotificationCenter'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
@@ -16,6 +16,34 @@ import { useRouter } from 'next/navigation'
|
||||
|
||||
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||
|
||||
// * Available Ciphera apps for the app switcher
|
||||
const CIPHERA_APPS: CipheraApp[] = [
|
||||
{
|
||||
id: 'pulse',
|
||||
name: 'Pulse',
|
||||
description: 'Your current app — Privacy-first analytics',
|
||||
icon: 'https://ciphera.net/pulse_icon_no_margins.png',
|
||||
href: 'https://pulse.ciphera.net',
|
||||
isAvailable: false, // * Current app
|
||||
},
|
||||
{
|
||||
id: 'drop',
|
||||
name: 'Drop',
|
||||
description: 'Secure file sharing',
|
||||
icon: 'https://ciphera.net/drop_icon_no_margins.png',
|
||||
href: 'https://drop.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
{
|
||||
id: 'auth',
|
||||
name: 'Auth',
|
||||
description: 'Your Ciphera account settings',
|
||||
icon: 'https://ciphera.net/auth_icon_no_margins.png',
|
||||
href: 'https://auth.ciphera.net',
|
||||
isAvailable: true,
|
||||
},
|
||||
]
|
||||
|
||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
@@ -87,6 +115,8 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
showPricing={true}
|
||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||
rightSideActions={auth.user ? <NotificationCenter /> : null}
|
||||
apps={CIPHERA_APPS}
|
||||
currentAppId="pulse"
|
||||
customNavItems={
|
||||
<>
|
||||
{!auth.user && (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
@@ -148,6 +148,23 @@ export default function SiteDashboardPage() {
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [])
|
||||
|
||||
// * Visibility-aware polling intervals
|
||||
// * Historical data: 60s when visible, paused when hidden
|
||||
// * Real-time data: 5s when visible, 30s when hidden
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// * Track visibility state
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === 'visible'
|
||||
setIsVisible(visible)
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}, [])
|
||||
|
||||
const loadData = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
@@ -204,18 +221,60 @@ export default function SiteDashboardPage() {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// Silently fail for realtime updates
|
||||
// * Silently fail for realtime updates
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
// * Visibility-aware polling for dashboard data (historical)
|
||||
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
|
||||
useEffect(() => {
|
||||
if (isSettingsLoaded) loadData()
|
||||
const interval = setInterval(() => {
|
||||
loadData(true)
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Initial load
|
||||
loadData()
|
||||
|
||||
// * Clear existing interval
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Only poll when visible (saves server resources when tab is backgrounded)
|
||||
if (isVisible) {
|
||||
dashboardIntervalRef.current = setInterval(() => {
|
||||
loadData(true)
|
||||
}, 60000) // * 60 seconds for historical data
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dashboardIntervalRef.current) {
|
||||
clearInterval(dashboardIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
|
||||
|
||||
// * Visibility-aware polling for realtime data
|
||||
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
|
||||
useEffect(() => {
|
||||
if (!isSettingsLoaded) return
|
||||
|
||||
// * Clear existing interval
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
|
||||
// * Different intervals based on visibility
|
||||
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
|
||||
|
||||
realtimeIntervalRef.current = setInterval(() => {
|
||||
loadRealtime()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
||||
}, interval)
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
|
||||
Reference in New Issue
Block a user