add:oauth

This commit is contained in:
2026-03-09 15:19:29 +09:00
parent fc611806a3
commit 685521f118
20 changed files with 607 additions and 294 deletions

View File

@@ -4,7 +4,6 @@ import { Tabs } from './components/ui/Tabs.tsx'
import { UserPage } from './pages/UserPage.tsx'
import { AdminPage } from './pages/AdminPage.tsx'
import { LoginPage } from './pages/LoginPage.tsx'
import { useStorageMode } from './hooks/useStorageMode.ts'
import { useAuth } from './hooks/useAuth.ts'
import { AuthBar } from './components/ui/AuthBar.tsx'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
@@ -16,14 +15,12 @@ function SupportApp() {
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
const [showLogin, setShowLogin] = useState(false)
const { user, authState, logout } = useAuth()
const storageMode = useStorageMode()
const urlError = new URLSearchParams(window.location.search).get('error')
if (showLogin || urlError) {
return (
<Layout
user={user}>
<Layout>
<LoginPage
error={urlError}
onBack={() => {
@@ -35,7 +32,7 @@ function SupportApp() {
)
}
if (authState === 'pending' || storageMode === 'pending') {
if (authState === 'pending') {
return (
<div className="flex min-h-screen items-center justify-center bg-bg-100">
<p className="text-sm text-fg-300">Loading...</p>
@@ -43,26 +40,23 @@ function SupportApp() {
)
}
const isGuest = authState === 'unauthenticated'
const tabs: { value: TabValue; label: string }[] = [
{
value: 'tickets',
label: user ? `${user.username}'s Tickets` : 'My Tickets',
label: 'My Tickets',
},
{ value: 'admin', label: 'Admin' },
]
return (
<Layout
user={user}
subHeader={
<AuthBar isGuest={isGuest} onLogin={() => setShowLogin(true)} onLogout={logout} user={user} />
<AuthBar user={user} onLogin={() => setShowLogin(true)} onLogout={logout} />
}
>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'tickets' && <UserPage storageMode={storageMode} />}
{activeTab === 'admin' && <AdminPage storageMode={storageMode} />}
{activeTab === 'tickets' && <UserPage />}
{activeTab === 'admin' && <AdminPage />}
</Layout>
)
}

View File

@@ -2,49 +2,41 @@ import type { User } from "../../lib/types"
import { GuestBanner } from "./GuestBanner"
interface AuthBarProps {
isGuest: boolean
user: User | null
onLogin: () => void
onLogout: () => void
user?: User | null
}
export function AuthBar({ isGuest, onLogin, onLogout, user }: AuthBarProps) {
return (
<>
{/* Auth bar — guest strip or user status strip */}
{isGuest ? (
<GuestBanner onLogin={onLogin} />
) : user ? (
<div className="w-full border-b border-border-100 bg-bg-200">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-2.5">
{/* Left: avatar + username */}
<div className="flex items-center gap-2">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="h-5 w-5 rounded-full object-cover ring-1 ring-border-100"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-bg-400 ring-1 ring-border-100">
<span className="text-[10px] font-medium text-fg-200">
{user.username[0].toUpperCase()}
</span>
</div>
)}
<span className="text-xs text-fg-200">{user.username}</span>
</div>
export function AuthBar({ user, onLogin, onLogout }: AuthBarProps) {
if (!user) return <GuestBanner onLogin={onLogin} />
{/* Right: sign out */}
<button
onClick={onLogout}
className="text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
>
Sign out
</button>
</div>
return (
<div className="w-full border-b border-border-100 bg-bg-200">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-2.5">
<div className="flex items-center gap-2">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="h-5 w-5 rounded-full object-cover ring-1 ring-border-100"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-bg-400 ring-1 ring-border-100">
<span className="text-[10px] font-medium text-fg-200">
{user.username[0].toUpperCase()}
</span>
</div>
)}
<span className="text-xs text-fg-200">{user.username}</span>
</div>
) : null}
</>
<button
onClick={onLogout}
className="text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
>
Sign out
</button>
</div>
</div>
)
}

View File

@@ -1,10 +1,8 @@
import type { User } from '../../lib/types.ts'
import { Navbar } from './Navbar.tsx'
interface LayoutProps {
children: React.ReactNode
subHeader?: React.ReactNode
user?: User | null
}
export function Layout({ children, subHeader }: LayoutProps) {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import type { User } from '../lib/types.ts'
import { env } from '../env.ts'
export type AuthState = 'pending' | 'authenticated' | 'unauthenticated'
@@ -8,16 +9,14 @@ export function useAuth() {
const [authState, setAuthState] = useState<AuthState>('pending')
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user)
setAuthState('authenticated')
} else {
setUser(null)
setAuthState('unauthenticated')
}
fetch(`${env.apiUrl}/api/auth/me`, { credentials: 'include' })
.then(res => {
if (!res.ok) throw new Error('unauthenticated')
return res.json()
})
.then((data: User) => {
setUser(data)
setAuthState('authenticated')
})
.catch(() => {
setUser(null)
@@ -26,7 +25,7 @@ export function useAuth() {
}, [])
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
await fetch(`${env.apiUrl}/api/auth/logout`, { method: 'POST', credentials: 'include' })
setUser(null)
setAuthState('unauthenticated')
}, [])

View File

@@ -1,27 +1,79 @@
import type { Ticket, TicketType } from './types.ts'
import type { Ticket, TicketType } from './types'
export type StorageMode = 'local' | 'remote'
const API = import.meta.env.VITE_API_URL ?? ''
const isProd = import.meta.env.PROD
// ─── Local (browser localStorage) ────────────────────────────
// ─── 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).
const KEY = 'support_tickets'
let csrfToken: string | null = null
function load(): Ticket[] {
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 ─────────────────────────────────────────────────────────────
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,
})
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()
}
// ─── Local (localStorage) adapter ────────────────────────────────────────────
const LOCAL_KEY = 'support_tickets'
function localGet(): Ticket[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
} catch {
return []
}
}
function save(tickets: Ticket[]): void {
localStorage.setItem(KEY, JSON.stringify(tickets))
function localSet(tickets: Ticket[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
}
const local = {
getTickets: (): Ticket[] => load(),
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket => {
export const localAdapter = {
getTickets: (): Ticket[] => localGet(),
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
const ticket: Ticket = {
id: crypto.randomUUID(),
userId: null,
@@ -31,49 +83,72 @@ const local = {
status: 'open',
createdAt: new Date().toISOString(),
}
save([ticket, ...load()])
localSet([...localGet(), ticket])
return ticket
},
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
const tickets = load().map(t => t.id === id ? { ...t, ...patch } : t)
save(tickets)
return tickets.find(t => t.id === id) ?? null
const tickets = localGet()
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): void => {
save(load().filter(t => t.id !== id))
deleteTicket: (id: string): boolean => {
const before = localGet()
const after = before.filter((t) => t.id !== id)
localSet(after)
return after.length < before.length
},
}
// ─── Remote (backend API) ─────────────────────────────────────
// ─── API adapter (falls back to local on 401) ─────────────────────────────────
const remote = {
getTickets: (): Promise<Ticket[]> =>
fetch('/api/tickets', { credentials: 'include' }).then(r => r.json()),
export const storage = {
async getTickets(): Promise<Ticket[]> {
try {
return await apiFetch<Ticket[]>('/api/tickets')
} catch {
return localAdapter.getTickets()
}
},
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Promise<Ticket> =>
fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
}).then(r => r.json()),
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
try {
return await apiFetch<Ticket>('/api/tickets', {
method: 'POST',
body: JSON.stringify(data),
})
} catch {
return localAdapter.createTicket(data)
}
},
updateTicket: (id: string, patch: Partial<Ticket>): Promise<Ticket> =>
fetch(`/api/tickets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(patch),
}).then(r => r.json()),
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
try {
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
})
} catch {
return localAdapter.updateTicket(id, patch)
}
},
deleteTicket: (id: string): Promise<void> =>
fetch(`/api/tickets/${id}`, { method: 'DELETE', credentials: 'include' }).then(() => undefined),
}
// ─── Resolver ─────────────────────────────────────────────────
export function getStorage(mode: StorageMode) {
return mode === 'remote' ? remote : local
async deleteTicket(id: string): Promise<boolean> {
try {
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
return true
} catch {
return localAdapter.deleteTicket(id)
}
},
async getAllTickets(): Promise<Ticket[]> {
try {
return await apiFetch<Ticket[]>('/api/tickets/all')
} catch {
return localAdapter.getTickets()
}
},
}

View File

@@ -1,12 +1,8 @@
import { useState, useEffect } from 'react'
import { AdminTable } from '../components/admin/AdminTable.tsx'
import { getStorage } from '../lib/storage.ts'
import { storage } from '../lib/storage.ts'
import type { Ticket } from '../lib/types.ts'
interface AdminPageProps {
storageMode: 'local' | 'remote'
}
interface StatCardProps {
label: string
value: number
@@ -21,18 +17,12 @@ function StatCard({ label, value }: StatCardProps) {
)
}
export function AdminPage({ storageMode }: AdminPageProps) {
const storage = getStorage(storageMode)
export function AdminPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
useEffect(() => {
const result = storage.getTickets()
if (result instanceof Promise) {
result.then(setTickets)
} else {
setTickets(result)
}
}, [storageMode])
storage.getTickets().then(setTickets)
}, [])
const stats = {
total: tickets.length,
@@ -48,7 +38,6 @@ export function AdminPage({ storageMode }: AdminPageProps) {
<p className="mt-0.5 text-sm text-fg-300">All tickets across the system</p>
</div>
{/* Stats row */}
<div className="mb-6 grid grid-cols-4 gap-3">
<StatCard label="Total" value={stats.total} />
<StatCard label="Open" value={stats.open} />

View File

@@ -1,4 +1,4 @@
import { env } from "@/env"
import { env } from "../env"
interface LoginPageProps {
onBack?: () => void

View File

@@ -4,37 +4,25 @@ import { Button } from '../components/ui/Button.tsx'
import { TicketTable } from '../components/tickets/TicketTable.tsx'
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
import { useModal } from '../hooks/useModal.ts'
import { getStorage } from '../lib/storage.ts'
import { storage } from '../lib/storage.ts'
import type { Ticket } from '../lib/types.ts'
interface UserPageProps {
storageMode: 'local' | 'remote'
}
export function UserPage({ storageMode }: UserPageProps) {
const storage = getStorage(storageMode)
export function UserPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
const newTicketModal = useModal()
useEffect(() => {
const result = storage.getTickets()
if (result instanceof Promise) {
result.then(setTickets)
} else {
setTickets(result)
}
}, [storageMode])
storage.getTickets().then(setTickets)
}, [])
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
const result = storage.createTicket(form)
const ticket = result instanceof Promise ? await result : result
const ticket = await storage.createTicket(form)
setTickets(prev => [ticket, ...prev])
newTicketModal.close()
}
const handleDelete = async (id: string) => {
const result = storage.deleteTicket(id)
if (result instanceof Promise) await result
await storage.deleteTicket(id)
setTickets(prev => prev.filter(t => t.id !== id))
}