update:routing
This commit is contained in:
@@ -55,8 +55,8 @@ function SupportApp() {
|
||||
}
|
||||
>
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
{activeTab === 'tickets' && <UserPage />}
|
||||
{activeTab === 'admin' && <AdminPage />}
|
||||
{activeTab === 'tickets' && <UserPage isAuthenticated={authState === 'authenticated'} />}
|
||||
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} />}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +1,18 @@
|
||||
import type { Ticket, TicketType } from './types'
|
||||
import { env } from '../env'
|
||||
|
||||
const API = import.meta.env.VITE_API_URL ?? ''
|
||||
const isProd = import.meta.env.PROD
|
||||
|
||||
// ─── CSRF ────────────────────────────────────────────────────────────────────
|
||||
// In production we fetch a CSRF token once and attach it to all mutating
|
||||
// requests via the x-csrf-token header (required by @fastify/csrf-protection).
|
||||
|
||||
let csrfToken: string | null = null
|
||||
|
||||
async function getCsrfToken(): Promise<string | null> {
|
||||
if (!isProd) return null
|
||||
if (csrfToken) return csrfToken
|
||||
const res = await fetch(`${API}/api/auth/csrf-token`, { credentials: 'include' })
|
||||
const json = await res.json()
|
||||
csrfToken = json.token ?? null
|
||||
return csrfToken
|
||||
}
|
||||
|
||||
// Invalidate cached token on 403 so the next call fetches a fresh one.
|
||||
function invalidateCsrf() {
|
||||
csrfToken = null
|
||||
}
|
||||
const API = env.apiUrl
|
||||
|
||||
// ─── Fetch helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
const isMutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
}
|
||||
|
||||
if (isMutating) {
|
||||
const token = await getCsrfToken()
|
||||
if (token) headers['x-csrf-token'] = token
|
||||
}
|
||||
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
||||
})
|
||||
|
||||
if (res.status === 401) throw new Error('unauthenticated')
|
||||
if (res.status === 403) {
|
||||
invalidateCsrf()
|
||||
throw new Error('forbidden')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error ${res.status}`)
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -73,6 +34,7 @@ function localSet(tickets: Ticket[]) {
|
||||
|
||||
export const localAdapter = {
|
||||
getTickets: (): Ticket[] => localGet(),
|
||||
|
||||
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
||||
const ticket: Ticket = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -83,28 +45,31 @@ export const localAdapter = {
|
||||
status: 'open',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
localSet([...localGet(), ticket])
|
||||
localSet([ticket, ...localGet()])
|
||||
return ticket
|
||||
},
|
||||
|
||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||
const tickets = localGet()
|
||||
const idx = tickets.findIndex((t) => t.id === id)
|
||||
const idx = tickets.findIndex(t => t.id === id)
|
||||
if (idx === -1) return null
|
||||
tickets[idx] = { ...tickets[idx], ...patch }
|
||||
localSet(tickets)
|
||||
return tickets[idx]
|
||||
},
|
||||
|
||||
deleteTicket: (id: string): boolean => {
|
||||
const before = localGet()
|
||||
const after = before.filter((t) => t.id !== id)
|
||||
const after = before.filter(t => t.id !== id)
|
||||
localSet(after)
|
||||
return after.length < before.length
|
||||
},
|
||||
}
|
||||
|
||||
// ─── API adapter (falls back to local on 401) ─────────────────────────────────
|
||||
// ─── Storage API ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const storage = {
|
||||
// User's own tickets — API when authenticated, localStorage when guest
|
||||
async getTickets(): Promise<Ticket[]> {
|
||||
try {
|
||||
return await apiFetch<Ticket[]>('/api/tickets')
|
||||
@@ -113,6 +78,16 @@ export const storage = {
|
||||
}
|
||||
},
|
||||
|
||||
// Admin view — all DB tickets when authenticated, localStorage when guest
|
||||
async getAllTickets(isAuthenticated: boolean): Promise<Ticket[]> {
|
||||
if (!isAuthenticated) return localAdapter.getTickets()
|
||||
try {
|
||||
return await apiFetch<Ticket[]>('/api/tickets/all')
|
||||
} catch {
|
||||
return localAdapter.getTickets()
|
||||
}
|
||||
},
|
||||
|
||||
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||
try {
|
||||
return await apiFetch<Ticket>('/api/tickets', {
|
||||
@@ -143,12 +118,4 @@ export const storage = {
|
||||
return localAdapter.deleteTicket(id)
|
||||
}
|
||||
},
|
||||
|
||||
async getAllTickets(): Promise<Ticket[]> {
|
||||
try {
|
||||
return await apiFetch<Ticket[]>('/api/tickets/all')
|
||||
} catch {
|
||||
return localAdapter.getTickets()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,12 +17,16 @@ function StatCard({ label, value }: StatCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
interface AdminPageProps {
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function AdminPage({ isAuthenticated }: AdminPageProps) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
storage.getTickets().then(setTickets)
|
||||
}, [])
|
||||
storage.getAllTickets(isAuthenticated).then(setTickets)
|
||||
}, [isAuthenticated])
|
||||
|
||||
const stats = {
|
||||
total: tickets.length,
|
||||
@@ -35,7 +39,9 @@ export function AdminPage() {
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold text-fg-100">Admin</h1>
|
||||
<p className="mt-0.5 text-sm text-fg-300">All tickets across the system</p>
|
||||
<p className="mt-0.5 text-sm text-fg-300">
|
||||
{isAuthenticated ? 'All tickets across the system' : 'Your local tickets'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3">
|
||||
|
||||
@@ -6,14 +6,19 @@ import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
||||
import { useModal } from '../hooks/useModal.ts'
|
||||
import { storage } from '../lib/storage.ts'
|
||||
import type { Ticket } from '../lib/types.ts'
|
||||
import { PlusIcon } from '../components/icons/plus.tsx'
|
||||
|
||||
export function UserPage() {
|
||||
interface UserPageProps {
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function UserPage({ isAuthenticated }: UserPageProps) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const newTicketModal = useModal()
|
||||
|
||||
useEffect(() => {
|
||||
storage.getTickets().then(setTickets)
|
||||
}, [])
|
||||
}, [isAuthenticated])
|
||||
|
||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||
const ticket = await storage.createTicket(form)
|
||||
@@ -36,9 +41,7 @@ export function UserPage() {
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={newTicketModal.open}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<PlusIcon className="size-4" />
|
||||
New Ticket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user