update:routing
This commit is contained in:
@@ -55,8 +55,8 @@ function SupportApp() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
{activeTab === 'tickets' && <UserPage />}
|
{activeTab === 'tickets' && <UserPage isAuthenticated={authState === 'authenticated'} />}
|
||||||
{activeTab === 'admin' && <AdminPage />}
|
{activeTab === 'admin' && <AdminPage isAuthenticated={authState === 'authenticated'} />}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,18 @@
|
|||||||
import type { Ticket, TicketType } from './types'
|
import type { Ticket, TicketType } from './types'
|
||||||
|
import { env } from '../env'
|
||||||
|
|
||||||
const API = import.meta.env.VITE_API_URL ?? ''
|
const API = env.apiUrl
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Fetch helper ─────────────────────────────────────────────────────────────
|
// ─── Fetch helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
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}`, {
|
const res = await fetch(`${API}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers,
|
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 401) throw new Error('unauthenticated')
|
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}`)
|
if (!res.ok) throw new Error(`API error ${res.status}`)
|
||||||
|
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +34,7 @@ function localSet(tickets: Ticket[]) {
|
|||||||
|
|
||||||
export const localAdapter = {
|
export const localAdapter = {
|
||||||
getTickets: (): Ticket[] => localGet(),
|
getTickets: (): Ticket[] => localGet(),
|
||||||
|
|
||||||
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
||||||
const ticket: Ticket = {
|
const ticket: Ticket = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -83,28 +45,31 @@ export const localAdapter = {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
localSet([...localGet(), ticket])
|
localSet([ticket, ...localGet()])
|
||||||
return ticket
|
return ticket
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||||
const tickets = localGet()
|
const tickets = localGet()
|
||||||
const idx = tickets.findIndex((t) => t.id === id)
|
const idx = tickets.findIndex(t => t.id === id)
|
||||||
if (idx === -1) return null
|
if (idx === -1) return null
|
||||||
tickets[idx] = { ...tickets[idx], ...patch }
|
tickets[idx] = { ...tickets[idx], ...patch }
|
||||||
localSet(tickets)
|
localSet(tickets)
|
||||||
return tickets[idx]
|
return tickets[idx]
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTicket: (id: string): boolean => {
|
deleteTicket: (id: string): boolean => {
|
||||||
const before = localGet()
|
const before = localGet()
|
||||||
const after = before.filter((t) => t.id !== id)
|
const after = before.filter(t => t.id !== id)
|
||||||
localSet(after)
|
localSet(after)
|
||||||
return after.length < before.length
|
return after.length < before.length
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── API adapter (falls back to local on 401) ─────────────────────────────────
|
// ─── Storage API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const storage = {
|
export const storage = {
|
||||||
|
// User's own tickets — API when authenticated, localStorage when guest
|
||||||
async getTickets(): Promise<Ticket[]> {
|
async getTickets(): Promise<Ticket[]> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket[]>('/api/tickets')
|
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> {
|
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<Ticket>('/api/tickets', {
|
return await apiFetch<Ticket>('/api/tickets', {
|
||||||
@@ -143,12 +118,4 @@ export const storage = {
|
|||||||
return localAdapter.deleteTicket(id)
|
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[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storage.getTickets().then(setTickets)
|
storage.getAllTickets(isAuthenticated).then(setTickets)
|
||||||
}, [])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tickets.length,
|
total: tickets.length,
|
||||||
@@ -35,7 +39,9 @@ export function AdminPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-lg font-semibold text-fg-100">Admin</h1>
|
<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>
|
||||||
|
|
||||||
<div className="mb-6 grid grid-cols-4 gap-3">
|
<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 { useModal } from '../hooks/useModal.ts'
|
||||||
import { storage } from '../lib/storage.ts'
|
import { storage } from '../lib/storage.ts'
|
||||||
import type { Ticket } from '../lib/types.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 [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storage.getTickets().then(setTickets)
|
storage.getTickets().then(setTickets)
|
||||||
}, [])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
const ticket = await storage.createTicket(form)
|
const ticket = await storage.createTicket(form)
|
||||||
@@ -36,9 +41,7 @@ export function UserPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={newTicketModal.open}>
|
<Button onClick={newTicketModal.open}>
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<PlusIcon className="size-4" />
|
||||||
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
New Ticket
|
New Ticket
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user