update:auth checks
This commit is contained in:
@@ -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`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
return res.json()
|
throw new ApiError(res.status, code, message);
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
if (!isAuthenticated) return localAdapter.createTicket(data);
|
||||||
|
return apiFetch<Ticket>("/api/tickets", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
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 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>,
|
||||||
|
): Promise<Ticket | null> {
|
||||||
|
if (!isAuthenticated) return localAdapter.updateTicket(id, patch);
|
||||||
|
return apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
})
|
});
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) throw err
|
|
||||||
return localAdapter.updateTicket(id, 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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user