update:routing

This commit is contained in:
2026-03-09 15:31:04 +09:00
parent 685521f118
commit 8a3c10e785
4 changed files with 41 additions and 65 deletions

View File

@@ -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>
)
}

View File

@@ -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()
}
},
}

View File

@@ -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">

View File

@@ -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>