feat: use session cookie auth for public dashboard password flow

handlePasswordSubmit now calls POST /public/sites/:id/auth which
sets an HttpOnly cookie. All subsequent API calls authenticate via
cookie automatically — no password in URLs, no captcha state needed
for data fetching. Simplifies share page state management.
This commit is contained in:
Usman Baig
2026-03-22 14:45:25 +01:00
parent 82a201a043
commit 430e6f5d48
2 changed files with 48 additions and 64 deletions

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, authenticatePublicDashboard, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { ApiError } from '@/lib/api/client'
@@ -39,9 +39,10 @@ export default function PublicDashboardPage() {
const [loading, setLoading] = useState(true)
const [data, setData] = useState<DashboardData | null>(null)
const [password, setPassword] = useState(passwordParam || '')
const [submittedPassword, setSubmittedPassword] = useState(passwordParam || '')
const [isPasswordProtected, setIsPasswordProtected] = useState(false)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [authLoading, setAuthLoading] = useState(false)
// Captcha State
const [captchaId, setCaptchaId] = useState('')
const [captchaSolution, setCaptchaSolution] = useState('')
@@ -92,68 +93,30 @@ export default function PublicDashboardPage() {
const loadRealtime = useCallback(async () => {
try {
const auth = {
password: passwordRef.current,
captcha: {
captcha_id: captchaIdRef.current,
captcha_solution: captchaSolutionRef.current,
captcha_token: captchaTokenRef.current
}
}
const realtimeData = await getPublicRealtime(siteId, auth)
const realtimeData = await getPublicRealtime(siteId)
if (data) {
setData({
...data,
realtime_visitors: realtimeData.visitors
})
setData({ ...data, realtime_visitors: realtimeData.visitors })
}
} catch (error) {
} catch {
// Silently fail for realtime updates
}
}, [siteId, data])
// Use refs for auth values so loadDashboard doesn't recreate on every keystroke/captcha change
const passwordRef = useRef(submittedPassword)
const captchaIdRef = useRef(captchaId)
const captchaSolutionRef = useRef(captchaSolution)
const captchaTokenRef = useRef(captchaToken)
passwordRef.current = submittedPassword
captchaIdRef.current = captchaId
captchaSolutionRef.current = captchaSolution
captchaTokenRef.current = captchaToken
const loadDashboard = useCallback(async (silent = false) => {
try {
if (!silent) setLoading(true)
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
const pw = passwordRef.current
const auth = {
password: pw,
captcha: {
captcha_id: captchaIdRef.current,
captcha_solution: captchaSolutionRef.current,
captcha_token: captchaTokenRef.current
}
}
const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([
getPublicDashboard(
siteId,
dateRange.start,
dateRange.end,
10,
interval,
pw,
auth.captcha
),
getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicStats(siteId, prevRange.start, prevRange.end, auth)
return getPublicStats(siteId, prevRange.start, prevRange.end)
})(),
(async () => {
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval, auth)
return getPublicDailyStats(siteId, prevRange.start, prevRange.end, interval)
})()
])
@@ -161,23 +124,11 @@ export default function PublicDashboardPage() {
setPrevStats(prevStatsData)
setPrevDailyStats(prevDailyStatsData)
setLastUpdatedAt(Date.now())
setIsPasswordProtected(false)
// Reset captcha
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} catch (error: unknown) {
const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
setIsPasswordProtected(true)
if (passwordRef.current) {
toast.error('Invalid password or captcha')
// Reset captcha on failure
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
}
} else if (apiErr?.status === 404) {
toast.error('Site not found')
} else if (!silent) {
@@ -203,12 +154,30 @@ export default function PublicDashboardPage() {
loadDashboard()
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
const handlePasswordSubmit = (e: React.FormEvent) => {
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Update ref immediately so loadDashboard reads the latest password
passwordRef.current = password
setSubmittedPassword(password)
loadDashboard()
setAuthLoading(true)
try {
await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution)
// Cookie is now set — load dashboard (cookie sent automatically)
setIsAuthenticated(true)
await loadDashboard()
} catch (error: unknown) {
const apiErr = error instanceof ApiError ? error : null
if (apiErr?.status === 401) {
const errData = apiErr.data as Record<string, unknown> | undefined
const errMsg = errData?.error as string | undefined
toast.error(errMsg || 'Invalid password or captcha')
} else {
toast.error('Authentication failed')
}
// Reset captcha on failure
setCaptchaId('')
setCaptchaSolution('')
setCaptchaToken('')
} finally {
setAuthLoading(false)
}
}
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)

View File

@@ -117,6 +117,21 @@ export interface FrustrationByPage {
unique_elements: number
}
// ─── Public Auth ─────────────────────────────────────────────────────
export function authenticatePublicDashboard(siteId: string, password: string, captchaToken?: string, captchaId?: string, captchaSolution?: string): Promise<{ status: string }> {
return apiRequest<{ status: string }>(`/public/sites/${siteId}/auth`, {
method: 'POST',
body: JSON.stringify({
password,
captcha_token: captchaToken || '',
captcha_id: captchaId || '',
captcha_solution: captchaSolution || '',
}),
credentials: 'include',
})
}
// ─── Helpers ────────────────────────────────────────────────────────
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {