From 3c28c117a02b67aeecc1e62994d256b16805883c Mon Sep 17 00:00:00 2001 From: kokopi Date: Mon, 9 Mar 2026 22:17:57 +0900 Subject: [PATCH] update:auth checks --- backend/src/routes/auth.ts | 3 +- frontend/src/hooks/useAuth.ts | 22 ++- frontend/src/lib/storage.ts | 208 +++++++++++--------------- frontend/src/pages/AdminPage.tsx | 8 +- frontend/src/pages/AdminStatsPage.tsx | 3 +- frontend/src/pages/UserPage.tsx | 9 +- 6 files changed, 123 insertions(+), 130 deletions(-) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 38ac34e..58ce387 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -79,7 +79,8 @@ export const authRouter: FastifyPluginAsync = async (fastify) => { } req.session.user = user; - reply.redirect(process.env.FRONTEND_URL ?? "http://localhost:5173"); + const base = process.env.FRONTEND_URL ?? "http://localhost:5173"; + reply.redirect(`${base}?login=1`); }, ); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 17a2b39..b3b3ca9 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -4,21 +4,40 @@ import { env } from '../env.ts' export type AuthState = 'pending' | 'authenticated' | 'unauthenticated' +const SESSION_HINT_KEY = 'auth_session_hint' +const hasSessionHint = () => localStorage.getItem(SESSION_HINT_KEY) === 'true' +const setSessionHint = (val: boolean) => + val + ? localStorage.setItem(SESSION_HINT_KEY, 'true') + : localStorage.removeItem(SESSION_HINT_KEY) + export function useAuth() { const [user, setUser] = useState(null) - const [authState, setAuthState] = useState('pending') + const [authState, setAuthState] = useState( + hasSessionHint() ? 'pending' : 'unauthenticated' + ) useEffect(() => { + const freshLogin = new URLSearchParams(window.location.search).has('login') + + // No hint and not a fresh OAuth redirect → skip the network check entirely. + if (!hasSessionHint() && !freshLogin) return + fetch(`${env.apiUrl}/api/auth/me`, { credentials: 'include' }) .then(res => { if (!res.ok) throw new Error('unauthenticated') return res.json() }) .then((data: User) => { + setSessionHint(true) setUser(data) setAuthState('authenticated') + // Clean the login param from the URL without triggering a navigation. + if (freshLogin) window.history.replaceState({}, '', window.location.pathname) }) .catch(() => { + // Session expired or cookie was cleared — clean up the stale hint. + setSessionHint(false) setUser(null) setAuthState('unauthenticated') }) @@ -26,6 +45,7 @@ export function useAuth() { const logout = useCallback(async () => { await fetch(`${env.apiUrl}/api/auth/logout`, { method: 'POST', credentials: 'include' }) + setSessionHint(false) setUser(null) setAuthState('unauthenticated') }, []) diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 53378a5..27aadf3 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,64 +1,64 @@ -import type { Ticket, TicketType } from './types' -import { env } from '../env' +import type { Ticket, TicketType } from "./types"; +import { env } from "../env"; -const API = env.apiUrl - -// ─── API error with structured body ────────────────────────────────────────── +const API = env.apiUrl; export class ApiError extends Error { - readonly status: number - readonly code: string + readonly status: number; + readonly code: string; constructor(status: number, code: string, message: string) { - super(message) - this.name = 'ApiError' - this.status = status - this.code = code + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; } } -// ─── Fetch helper ───────────────────────────────────────────────────────────── - async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API}${path}`, { ...init, - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, - }) + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); if (!res.ok) { // Try to parse a structured error body; fall back to a generic message - let code = `http_${res.status}` - let message = `API error ${res.status}` + let code = `http_${res.status}`; + let message = `API error ${res.status}`; try { - const body = await res.json() - if (body?.error) code = body.error - if (body?.message) message = body.message - } catch { /* non-JSON body — keep defaults */ } - throw new ApiError(res.status, code, message) + const body = await res.json(); + if (body?.error) code = body.error; + if (body?.message) message = body.message; + } catch { + /* non-JSON body — keep defaults */ + } + throw new ApiError(res.status, code, message); } - return res.json() + return res.json(); } -// ─── Local (localStorage) adapter ──────────────────────────────────────────── - -const LOCAL_KEY = 'support_tickets' +const LOCAL_KEY = "support_tickets"; function localGet(): Ticket[] { try { - return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') + return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]"); } catch { - return [] + return []; } } function localSet(tickets: Ticket[]) { - localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) + localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)); } export const localAdapter = { getTickets: (): Ticket[] => localGet(), - createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => { + createTicket: (data: { + subject: string; + description: string; + type: TicketType; + }): Ticket => { const ticket: Ticket = { id: crypto.randomUUID(), userId: null, @@ -66,56 +66,49 @@ export const localAdapter = { subject: data.subject, description: data.description, type: data.type, - status: 'open', + status: "open", createdAt: new Date().toISOString(), - } - localSet([ticket, ...localGet()]) - return ticket + }; + localSet([ticket, ...localGet()]); + return ticket; }, updateTicket: (id: string, patch: Partial): Ticket | 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] + 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): boolean => { - const before = localGet() - const after = before.filter(t => t.id !== id) - localSet(after) - return after.length < before.length + const before = localGet(); + const after = before.filter((t) => t.id !== id); + localSet(after); + return after.length < before.length; }, -} - -// ─── Paginated response envelope ───────────────────────────────────────────── +}; export interface PaginatedResponse { - data: T[] - total: number - page: number - pageSize: number - totalPages: number + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; } export interface TicketFilters { - status?: Ticket['status'] - type?: TicketType - mine?: boolean // restrict to the current user's tickets + status?: Ticket["status"]; + type?: TicketType; + mine?: boolean; // restrict to the current user's tickets } -// ─── Storage API ────────────────────────────────────────────────────────────── - export const storage = { // User's own tickets — API when authenticated, localStorage when guest - async getTickets(): Promise { - try { - return await apiFetch('/api/tickets') - } catch { - return localAdapter.getTickets() - } + async getTickets(isAuthenticated: boolean): Promise { + if (!isAuthenticated) return localAdapter.getTickets(); + return apiFetch("/api/tickets"); }, // Admin view — paginated from API when authenticated, sliced localStorage when guest @@ -126,72 +119,53 @@ export const storage = { filters: TicketFilters = {}, ): Promise> { if (!isAuthenticated) { - let all = localAdapter.getTickets() - if (filters.status) all = all.filter(t => t.status === filters.status) - if (filters.type) all = all.filter(t => t.type === filters.type) - const start = (page - 1) * pageSize + let all = localAdapter.getTickets(); + if (filters.status) all = all.filter((t) => t.status === filters.status); + if (filters.type) all = all.filter((t) => t.type === filters.type); + const start = (page - 1) * pageSize; return { data: all.slice(start, start + pageSize), total: all.length, page, pageSize, totalPages: Math.max(1, Math.ceil(all.length / pageSize)), - } + }; } - const params = new URLSearchParams({ page: String(page) }) - if (filters.status) params.set('status', filters.status) - if (filters.type) params.set('type', filters.type) - if (filters.mine) params.set('mine', 'true') + const params = new URLSearchParams({ page: String(page) }); + if (filters.status) params.set("status", filters.status); + if (filters.type) params.set("type", filters.type); + if (filters.mine) params.set("mine", "true"); - try { - return await apiFetch>(`/api/tickets/all?${params}`) - } catch { - const all = localAdapter.getTickets() - const start = (page - 1) * pageSize - return { - data: all.slice(start, start + pageSize), - total: all.length, - page, - pageSize, - totalPages: Math.max(1, Math.ceil(all.length / pageSize)), - } - } + return apiFetch>(`/api/tickets/all?${params}`); }, - async createTicket(data: { subject: string; description: string; type: TicketType }): Promise { - try { - return await apiFetch('/api/tickets', { - method: 'POST', - body: JSON.stringify(data), - }) - } catch (err) { - // Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently - // fall back to localStorage, as these are intentional rejections from the server. - if (err instanceof ApiError) throw err - return localAdapter.createTicket(data) - } + async createTicket( + isAuthenticated: boolean, + data: { subject: string; description: string; type: TicketType }, + ): Promise { + if (!isAuthenticated) return localAdapter.createTicket(data); + return apiFetch("/api/tickets", { + method: "POST", + body: JSON.stringify(data), + }); }, - async updateTicket(id: string, patch: Partial): Promise { - try { - return await apiFetch(`/api/tickets/${id}`, { - method: 'PATCH', - body: JSON.stringify(patch), - }) - } catch (err) { - if (err instanceof ApiError) throw err - return localAdapter.updateTicket(id, patch) - } + async updateTicket( + isAuthenticated: boolean, + id: string, + patch: Partial, + ): Promise { + if (!isAuthenticated) return localAdapter.updateTicket(id, patch); + return apiFetch(`/api/tickets/${id}`, { + method: "PATCH", + body: JSON.stringify(patch), + }); }, - async deleteTicket(id: string): Promise { - try { - await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) - return true - } catch (err) { - if (err instanceof ApiError) throw err - return localAdapter.deleteTicket(id) - } + async deleteTicket(isAuthenticated: boolean, id: string): Promise { + if (!isAuthenticated) return localAdapter.deleteTicket(id); + await apiFetch(`/api/tickets/${id}`, { method: "DELETE" }); + return true; }, -} +}; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 41dfc4c..93a17f3 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -182,7 +182,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) { const handleCloseTicket = async (id: string) => { try { - const updated = await storage.updateTicket(id, { status: 'closed' }) + const updated = await storage.updateTicket(isAuthenticated, id, { status: 'closed' }) if (updated) { setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) setSelectedTicket(updated) @@ -194,7 +194,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) { const handleReopenTicket = async (id: string) => { try { - const updated = await storage.updateTicket(id, { status: 'open' }) + const updated = await storage.updateTicket(isAuthenticated, id, { status: 'open' }) if (updated) { setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) setSelectedTicket(updated) @@ -206,7 +206,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) { const handleDeleteTicket = async (id: string) => { try { - await storage.deleteTicket(id) + await storage.deleteTicket(isAuthenticated, id) handleDetailClose() await refetch() } catch { @@ -218,7 +218,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) { if (selection.size === 0) return setBatchDeleting(true) try { - await Promise.all([...selection].map(id => storage.deleteTicket(id))) + await Promise.all([...selection].map(id => storage.deleteTicket(isAuthenticated, id))) setSelection(new Set()) await refetch() } finally { diff --git a/frontend/src/pages/AdminStatsPage.tsx b/frontend/src/pages/AdminStatsPage.tsx index a741660..aac84d9 100644 --- a/frontend/src/pages/AdminStatsPage.tsx +++ b/frontend/src/pages/AdminStatsPage.tsx @@ -188,8 +188,7 @@ export function AdminStatsPage({ isAuthenticated }: AdminStatsPageProps) { } setTickets(all) } else { - // Guest — use their local tickets only - const local = await storage.getTickets() + const local = await storage.getTickets(false) setTickets(local) } setLoading(false) diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index b484e20..3148984 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -77,7 +77,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const showLimitScreen = atLimit || serverLimitHit useEffect(() => { - storage.getTickets().then(setTickets) + storage.getTickets(isAuthenticated).then(setTickets) }, [isAuthenticated]) const handleNewClose = () => { @@ -99,7 +99,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) { const handleCloseTicket = async (id: string) => { try { - const updated = await storage.updateTicket(id, { status: 'closed' }) + const updated = await storage.updateTicket(isAuthenticated, id, { status: 'closed' }) if (updated) { setTickets(prev => prev.map(t => t.id === id ? updated : t)) setSelectedTicket(updated) @@ -113,16 +113,15 @@ export function UserPage({ isAuthenticated }: UserPageProps) { if (atLimit) return setContentError(null) try { - const ticket = await storage.createTicket(form) + const ticket = await storage.createTicket(isAuthenticated, form) setTickets(prev => [ticket, ...prev]) newTicketModal.close() } catch (err) { if (err instanceof ApiError) { if (err.code === 'ticket_limit_reached') { setServerLimitHit(true) - storage.getTickets().then(setTickets) + storage.getTickets(isAuthenticated).then(setTickets) } else if (err.code === 'profanity') { - // Surface the server's message directly — it says which field was flagged setContentError(err.message) } }