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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
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 { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { ApiError } from '@/lib/api/client'
|
import { ApiError } from '@/lib/api/client'
|
||||||
@@ -39,9 +39,10 @@ export default function PublicDashboardPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [data, setData] = useState<DashboardData | null>(null)
|
const [data, setData] = useState<DashboardData | null>(null)
|
||||||
const [password, setPassword] = useState(passwordParam || '')
|
const [password, setPassword] = useState(passwordParam || '')
|
||||||
const [submittedPassword, setSubmittedPassword] = useState(passwordParam || '')
|
|
||||||
const [isPasswordProtected, setIsPasswordProtected] = useState(false)
|
const [isPasswordProtected, setIsPasswordProtected] = useState(false)
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
|
|
||||||
// Captcha State
|
// Captcha State
|
||||||
const [captchaId, setCaptchaId] = useState('')
|
const [captchaId, setCaptchaId] = useState('')
|
||||||
const [captchaSolution, setCaptchaSolution] = useState('')
|
const [captchaSolution, setCaptchaSolution] = useState('')
|
||||||
@@ -92,68 +93,30 @@ export default function PublicDashboardPage() {
|
|||||||
|
|
||||||
const loadRealtime = useCallback(async () => {
|
const loadRealtime = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const auth = {
|
const realtimeData = await getPublicRealtime(siteId)
|
||||||
password: passwordRef.current,
|
|
||||||
captcha: {
|
|
||||||
captcha_id: captchaIdRef.current,
|
|
||||||
captcha_solution: captchaSolutionRef.current,
|
|
||||||
captcha_token: captchaTokenRef.current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const realtimeData = await getPublicRealtime(siteId, auth)
|
|
||||||
if (data) {
|
if (data) {
|
||||||
setData({
|
setData({ ...data, realtime_visitors: realtimeData.visitors })
|
||||||
...data,
|
|
||||||
realtime_visitors: realtimeData.visitors
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Silently fail for realtime updates
|
// Silently fail for realtime updates
|
||||||
}
|
}
|
||||||
}, [siteId, data])
|
}, [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) => {
|
const loadDashboard = useCallback(async (silent = false) => {
|
||||||
try {
|
try {
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
|
|
||||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
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([
|
const [dashboardData, prevStatsData, prevDailyStatsData] = await Promise.all([
|
||||||
getPublicDashboard(
|
getPublicDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
|
||||||
siteId,
|
|
||||||
dateRange.start,
|
|
||||||
dateRange.end,
|
|
||||||
10,
|
|
||||||
interval,
|
|
||||||
pw,
|
|
||||||
auth.captcha
|
|
||||||
),
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
||||||
return getPublicStats(siteId, prevRange.start, prevRange.end, auth)
|
return getPublicStats(siteId, prevRange.start, prevRange.end)
|
||||||
})(),
|
})(),
|
||||||
(async () => {
|
(async () => {
|
||||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
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)
|
setPrevStats(prevStatsData)
|
||||||
setPrevDailyStats(prevDailyStatsData)
|
setPrevDailyStats(prevDailyStatsData)
|
||||||
setLastUpdatedAt(Date.now())
|
setLastUpdatedAt(Date.now())
|
||||||
|
|
||||||
setIsPasswordProtected(false)
|
setIsPasswordProtected(false)
|
||||||
// Reset captcha
|
|
||||||
setCaptchaId('')
|
|
||||||
setCaptchaSolution('')
|
|
||||||
setCaptchaToken('')
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const apiErr = error instanceof ApiError ? error : null
|
const apiErr = error instanceof ApiError ? error : null
|
||||||
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
|
if (apiErr?.status === 401 && (apiErr.data as Record<string, unknown>)?.is_protected) {
|
||||||
setIsPasswordProtected(true)
|
setIsPasswordProtected(true)
|
||||||
if (passwordRef.current) {
|
|
||||||
toast.error('Invalid password or captcha')
|
|
||||||
// Reset captcha on failure
|
|
||||||
setCaptchaId('')
|
|
||||||
setCaptchaSolution('')
|
|
||||||
setCaptchaToken('')
|
|
||||||
}
|
|
||||||
} else if (apiErr?.status === 404) {
|
} else if (apiErr?.status === 404) {
|
||||||
toast.error('Site not found')
|
toast.error('Site not found')
|
||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
@@ -203,12 +154,30 @@ export default function PublicDashboardPage() {
|
|||||||
loadDashboard()
|
loadDashboard()
|
||||||
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
|
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
|
||||||
|
|
||||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Update ref immediately so loadDashboard reads the latest password
|
setAuthLoading(true)
|
||||||
passwordRef.current = password
|
try {
|
||||||
setSubmittedPassword(password)
|
await authenticatePublicDashboard(siteId, password, captchaToken, captchaId, captchaSolution)
|
||||||
loadDashboard()
|
// 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)
|
const showSkeleton = useMinimumLoading(loading && !data && !isPasswordProtected)
|
||||||
|
|||||||
@@ -117,6 +117,21 @@ export interface FrustrationByPage {
|
|||||||
unique_elements: number
|
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 ────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
function appendAuthParams(params: URLSearchParams, auth?: AuthParams) {
|
||||||
|
|||||||
Reference in New Issue
Block a user