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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user