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

View File

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

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[]>([]) 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">

View File

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