add:oauth
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}, [])
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from "@/env"
|
||||
import { env } from "../env"
|
||||
|
||||
interface LoginPageProps {
|
||||
onBack?: () => void
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user