update:auth checks

This commit is contained in:
2026-03-09 22:17:57 +09:00
parent 1f41741450
commit 3c28c117a0
6 changed files with 123 additions and 130 deletions

View File

@@ -79,7 +79,8 @@ export const authRouter: FastifyPluginAsync = async (fastify) => {
} }
req.session.user = user; 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`);
}, },
); );

View File

@@ -4,21 +4,40 @@ import { env } from '../env.ts'
export type AuthState = 'pending' | 'authenticated' | 'unauthenticated' 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() { export function useAuth() {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [authState, setAuthState] = useState<AuthState>('pending') const [authState, setAuthState] = useState<AuthState>(
hasSessionHint() ? 'pending' : 'unauthenticated'
)
useEffect(() => { 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' }) fetch(`${env.apiUrl}/api/auth/me`, { credentials: 'include' })
.then(res => { .then(res => {
if (!res.ok) throw new Error('unauthenticated') if (!res.ok) throw new Error('unauthenticated')
return res.json() return res.json()
}) })
.then((data: User) => { .then((data: User) => {
setSessionHint(true)
setUser(data) setUser(data)
setAuthState('authenticated') setAuthState('authenticated')
// Clean the login param from the URL without triggering a navigation.
if (freshLogin) window.history.replaceState({}, '', window.location.pathname)
}) })
.catch(() => { .catch(() => {
// Session expired or cookie was cleared — clean up the stale hint.
setSessionHint(false)
setUser(null) setUser(null)
setAuthState('unauthenticated') setAuthState('unauthenticated')
}) })
@@ -26,6 +45,7 @@ export function useAuth() {
const logout = useCallback(async () => { const logout = useCallback(async () => {
await fetch(`${env.apiUrl}/api/auth/logout`, { method: 'POST', credentials: 'include' }) await fetch(`${env.apiUrl}/api/auth/logout`, { method: 'POST', credentials: 'include' })
setSessionHint(false)
setUser(null) setUser(null)
setAuthState('unauthenticated') setAuthState('unauthenticated')
}, []) }, [])

View File

@@ -1,64 +1,64 @@
import type { Ticket, TicketType } from './types' import type { Ticket, TicketType } from "./types";
import { env } from '../env' import { env } from "../env";
const API = env.apiUrl const API = env.apiUrl;
// ─── API error with structured body ──────────────────────────────────────────
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number readonly status: number;
readonly code: string readonly code: string;
constructor(status: number, code: string, message: string) { constructor(status: number, code: string, message: string) {
super(message) super(message);
this.name = 'ApiError' this.name = "ApiError";
this.status = status this.status = status;
this.code = code this.code = code;
} }
} }
// ─── Fetch helper ─────────────────────────────────────────────────────────────
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> { async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
...init, ...init,
credentials: 'include', credentials: "include",
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
}) });
if (!res.ok) { if (!res.ok) {
// Try to parse a structured error body; fall back to a generic message // Try to parse a structured error body; fall back to a generic message
let code = `http_${res.status}` let code = `http_${res.status}`;
let message = `API error ${res.status}` let message = `API error ${res.status}`;
try { try {
const body = await res.json() const body = await res.json();
if (body?.error) code = body.error if (body?.error) code = body.error;
if (body?.message) message = body.message if (body?.message) message = body.message;
} catch { /* non-JSON body — keep defaults */ } } catch {
throw new ApiError(res.status, code, message) /* 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[] { function localGet(): Ticket[] {
try { try {
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]");
} catch { } catch {
return [] return [];
} }
} }
function localSet(tickets: Ticket[]) { function localSet(tickets: Ticket[]) {
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets));
} }
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(),
userId: null, userId: null,
@@ -66,56 +66,49 @@ export const localAdapter = {
subject: data.subject, subject: data.subject,
description: data.description, description: data.description,
type: data.type, type: data.type,
status: 'open', status: "open",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
} };
localSet([ticket, ...localGet()]) 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;
}, },
} };
// ─── Paginated response envelope ─────────────────────────────────────────────
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
data: T[] data: T[];
total: number total: number;
page: number page: number;
pageSize: number pageSize: number;
totalPages: number totalPages: number;
} }
export interface TicketFilters { export interface TicketFilters {
status?: Ticket['status'] status?: Ticket["status"];
type?: TicketType type?: TicketType;
mine?: boolean // restrict to the current user's tickets mine?: boolean; // restrict to the current user's tickets
} }
// ─── Storage API ──────────────────────────────────────────────────────────────
export const storage = { export const storage = {
// User's own tickets — API when authenticated, localStorage when guest // User's own tickets — API when authenticated, localStorage when guest
async getTickets(): Promise<Ticket[]> { async getTickets(isAuthenticated: boolean): Promise<Ticket[]> {
try { if (!isAuthenticated) return localAdapter.getTickets();
return await apiFetch<Ticket[]>('/api/tickets') return apiFetch<Ticket[]>("/api/tickets");
} catch {
return localAdapter.getTickets()
}
}, },
// Admin view — paginated from API when authenticated, sliced localStorage when guest // Admin view — paginated from API when authenticated, sliced localStorage when guest
@@ -126,72 +119,53 @@ export const storage = {
filters: TicketFilters = {}, filters: TicketFilters = {},
): Promise<PaginatedResponse<Ticket>> { ): Promise<PaginatedResponse<Ticket>> {
if (!isAuthenticated) { if (!isAuthenticated) {
let all = localAdapter.getTickets() let all = localAdapter.getTickets();
if (filters.status) all = all.filter(t => t.status === filters.status) if (filters.status) all = all.filter((t) => t.status === filters.status);
if (filters.type) all = all.filter(t => t.type === filters.type) if (filters.type) all = all.filter((t) => t.type === filters.type);
const start = (page - 1) * pageSize const start = (page - 1) * pageSize;
return { return {
data: all.slice(start, start + pageSize), data: all.slice(start, start + pageSize),
total: all.length, total: all.length,
page, page,
pageSize, pageSize,
totalPages: Math.max(1, Math.ceil(all.length / pageSize)), totalPages: Math.max(1, Math.ceil(all.length / pageSize)),
} };
} }
const params = new URLSearchParams({ page: String(page) }) const params = new URLSearchParams({ page: String(page) });
if (filters.status) params.set('status', filters.status) if (filters.status) params.set("status", filters.status);
if (filters.type) params.set('type', filters.type) if (filters.type) params.set("type", filters.type);
if (filters.mine) params.set('mine', 'true') if (filters.mine) params.set("mine", "true");
try { return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`);
return await apiFetch<PaginatedResponse<Ticket>>(`/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)),
}
}
}, },
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> { async createTicket(
try { isAuthenticated: boolean,
return await apiFetch<Ticket>('/api/tickets', { data: { subject: string; description: string; type: TicketType },
method: 'POST', ): Promise<Ticket> {
body: JSON.stringify(data), if (!isAuthenticated) return localAdapter.createTicket(data);
}) return apiFetch<Ticket>("/api/tickets", {
} catch (err) { method: "POST",
// Re-throw structured API errors (e.g. profanity, ticket limit) — don't silently body: JSON.stringify(data),
// fall back to localStorage, as these are intentional rejections from the server. });
if (err instanceof ApiError) throw err
return localAdapter.createTicket(data)
}
}, },
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> { async updateTicket(
try { isAuthenticated: boolean,
return await apiFetch<Ticket>(`/api/tickets/${id}`, { id: string,
method: 'PATCH', patch: Partial<Ticket>,
body: JSON.stringify(patch), ): Promise<Ticket | null> {
}) if (!isAuthenticated) return localAdapter.updateTicket(id, patch);
} catch (err) { return apiFetch<Ticket>(`/api/tickets/${id}`, {
if (err instanceof ApiError) throw err method: "PATCH",
return localAdapter.updateTicket(id, patch) body: JSON.stringify(patch),
} });
}, },
async deleteTicket(id: string): Promise<boolean> { async deleteTicket(isAuthenticated: boolean, id: string): Promise<boolean> {
try { if (!isAuthenticated) return localAdapter.deleteTicket(id);
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) await apiFetch(`/api/tickets/${id}`, { method: "DELETE" });
return true return true;
} catch (err) {
if (err instanceof ApiError) throw err
return localAdapter.deleteTicket(id)
}
}, },
} };

View File

@@ -182,7 +182,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
const handleCloseTicket = async (id: string) => { const handleCloseTicket = async (id: string) => {
try { try {
const updated = await storage.updateTicket(id, { status: 'closed' }) const updated = await storage.updateTicket(isAuthenticated, id, { status: 'closed' })
if (updated) { if (updated) {
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
setSelectedTicket(updated) setSelectedTicket(updated)
@@ -194,7 +194,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
const handleReopenTicket = async (id: string) => { const handleReopenTicket = async (id: string) => {
try { try {
const updated = await storage.updateTicket(id, { status: 'open' }) const updated = await storage.updateTicket(isAuthenticated, id, { status: 'open' })
if (updated) { if (updated) {
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
setSelectedTicket(updated) setSelectedTicket(updated)
@@ -206,7 +206,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
const handleDeleteTicket = async (id: string) => { const handleDeleteTicket = async (id: string) => {
try { try {
await storage.deleteTicket(id) await storage.deleteTicket(isAuthenticated, id)
handleDetailClose() handleDetailClose()
await refetch() await refetch()
} catch { } catch {
@@ -218,7 +218,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
if (selection.size === 0) return if (selection.size === 0) return
setBatchDeleting(true) setBatchDeleting(true)
try { try {
await Promise.all([...selection].map(id => storage.deleteTicket(id))) await Promise.all([...selection].map(id => storage.deleteTicket(isAuthenticated, id)))
setSelection(new Set()) setSelection(new Set())
await refetch() await refetch()
} finally { } finally {

View File

@@ -188,8 +188,7 @@ export function AdminStatsPage({ isAuthenticated }: AdminStatsPageProps) {
} }
setTickets(all) setTickets(all)
} else { } else {
// Guest — use their local tickets only const local = await storage.getTickets(false)
const local = await storage.getTickets()
setTickets(local) setTickets(local)
} }
setLoading(false) setLoading(false)

View File

@@ -77,7 +77,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
const showLimitScreen = atLimit || serverLimitHit const showLimitScreen = atLimit || serverLimitHit
useEffect(() => { useEffect(() => {
storage.getTickets().then(setTickets) storage.getTickets(isAuthenticated).then(setTickets)
}, [isAuthenticated]) }, [isAuthenticated])
const handleNewClose = () => { const handleNewClose = () => {
@@ -99,7 +99,7 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
const handleCloseTicket = async (id: string) => { const handleCloseTicket = async (id: string) => {
try { try {
const updated = await storage.updateTicket(id, { status: 'closed' }) const updated = await storage.updateTicket(isAuthenticated, id, { status: 'closed' })
if (updated) { if (updated) {
setTickets(prev => prev.map(t => t.id === id ? updated : t)) setTickets(prev => prev.map(t => t.id === id ? updated : t))
setSelectedTicket(updated) setSelectedTicket(updated)
@@ -113,16 +113,15 @@ export function UserPage({ isAuthenticated }: UserPageProps) {
if (atLimit) return if (atLimit) return
setContentError(null) setContentError(null)
try { try {
const ticket = await storage.createTicket(form) const ticket = await storage.createTicket(isAuthenticated, form)
setTickets(prev => [ticket, ...prev]) setTickets(prev => [ticket, ...prev])
newTicketModal.close() newTicketModal.close()
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.code === 'ticket_limit_reached') { if (err.code === 'ticket_limit_reached') {
setServerLimitHit(true) setServerLimitHit(true)
storage.getTickets().then(setTickets) storage.getTickets(isAuthenticated).then(setTickets)
} else if (err.code === 'profanity') { } else if (err.code === 'profanity') {
// Surface the server's message directly — it says which field was flagged
setContentError(err.message) setContentError(err.message)
} }
} }