diff --git a/backend/drizzle/0003_bitter_ink.sql b/backend/drizzle/0003_bitter_ink.sql new file mode 100644 index 0000000..597089a --- /dev/null +++ b/backend/drizzle/0003_bitter_ink.sql @@ -0,0 +1,16 @@ +CREATE TABLE `sessions` ( + `id` text PRIMARY KEY NOT NULL, + `data` text NOT NULL, + `expires_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `googleId` text NOT NULL, + `username` text NOT NULL, + `avatarUrl` text, + `createdAt` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_googleId_unique` ON `users` (`googleId`);--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7a203e1 --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,164 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "16b0dd57-a0de-4237-813b-455b4b8c0157", + "prevId": "ad5d265f-896b-4cc1-b477-d00583523a0b", + "tables": { + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatarUrl": { + "name": "avatarUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_googleId_unique": { + "name": "users_googleId_unique", + "columns": [ + "googleId" + ], + "isUnique": true + }, + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 02daaa3..f2ca773 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1772964242556, "tag": "0002_careful_micromax", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1773034525409, + "tag": "0003_bitter_ink", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 2cb70ab..88bfcdc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "latest", + "@fastify/csrf-protection": "^7.1.0", "@fastify/session": "^11.1.1", "drizzle-orm": "^0.45.1", "fastify": "latest", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8d4a818..2ebe7dc 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -31,3 +31,9 @@ export const users = sqliteTable('users', { avatarUrl: text('avatarUrl'), // Google profile picture createdAt: text('createdAt').notNull(), }) + +export const sessions = sqliteTable('sessions', { + id: text('id').primaryKey(), + data: text('data').notNull(), + expiresAt: int('expires_at').notNull(), // unix ms +}) diff --git a/backend/src/db/sessionStore.ts b/backend/src/db/sessionStore.ts new file mode 100644 index 0000000..99244a9 --- /dev/null +++ b/backend/src/db/sessionStore.ts @@ -0,0 +1,70 @@ +import type { Session } from 'fastify' +import { eq, lt } from 'drizzle-orm' +import { db } from './index.js' +import { sessions } from './schema.js' + +type Callback = (err?: any) => void +type CallbackSession = (err: any, result?: Session | null) => void + +export class SqliteSessionStore { + private prune() { + db.delete(sessions).where(lt(sessions.expiresAt, Date.now())).run() + } + + constructor() { + this.prune() + setInterval(() => this.prune(), 60 * 60 * 1000).unref() + } + + get(sessionId: string, callback: CallbackSession): void { + try { + const row = db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .get() + + if (!row) return callback(null, null) + + if (row.expiresAt < Date.now()) { + this.destroy(sessionId, () => {}) + return callback(null, null) + } + + callback(null, JSON.parse(row.data) as Session) + } catch (err) { + callback(err) + } + } + + set(sessionId: string, session: Session, callback: Callback): void { + try { + const expiresAt = + session.cookie.expires instanceof Date + ? session.cookie.expires.getTime() + : Date.now() + (session.cookie.originalMaxAge ?? 7 * 24 * 60 * 60 * 1000) + + db + .insert(sessions) + .values({ id: sessionId, data: JSON.stringify(session), expiresAt }) + .onConflictDoUpdate({ + target: sessions.id, + set: { data: JSON.stringify(session), expiresAt }, + }) + .run() + + callback() + } catch (err) { + callback(err) + } + } + + destroy(sessionId: string, callback: Callback): void { + try { + db.delete(sessions).where(eq(sessions.id, sessionId)).run() + callback() + } catch (err) { + callback(err) + } + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index e2adedf..1ff5f09 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,14 +2,17 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import cookie from '@fastify/cookie' import session from '@fastify/session' -import { authMiddleware } from './middleware/auth.ts' -import { storageMiddleware } from './middleware/storage.ts' -import { storageModeRouter } from './routes/storageMode.ts' -import { ticketsRouter } from './routes/tickets.ts' -import { authRouter } from './routes/auth.ts' +import csrf from '@fastify/csrf-protection' + +import { authMiddleware } from './middleware/auth.js' +import { storageMiddleware } from './middleware/storage.js' +import { ticketsRouter } from './routes/tickets.js' +import { authRouter } from './routes/auth.js' +import { SqliteSessionStore } from './db/sessionStore.js' + +const isProd = process.env.NODE_ENV === 'production' const app = Fastify({ logger: true }) -const PORT = Number(process.env.PORT) || 4500 await app.register(cors, { origin: process.env.FRONTEND_URL ?? 'http://localhost:5173', @@ -19,20 +22,27 @@ await app.register(cors, { await app.register(cookie) await app.register(session, { - secret: process.env.SESSION_SECRET ?? 'dev-secret-change-in-production-min-32-chars!!', + secret: process.env.SESSION_SECRET!, + store: new SqliteSessionStore(), // ← persistent SQLite store cookie: { - secure: process.env.NODE_ENV === 'production', httpOnly: true, - maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days + secure: isProd, // HTTPS-only in production + sameSite: isProd ? 'strict' : 'lax', // strict in prod, lax in dev + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms }, + saveUninitialized: false, }) +if (isProd) { + await app.register(csrf, { + sessionPlugin: '@fastify/session', + }) +} + await app.register(authMiddleware) await app.register(storageMiddleware) -await app.register(storageModeRouter, { prefix: '/api/storage-mode' }) -await app.register(ticketsRouter, { prefix: '/api/tickets' }) -await app.register(authRouter, { prefix: '/api/auth' }) +await app.register(authRouter, { prefix: '/api/auth' }) +await app.register(ticketsRouter, { prefix: '/api/tickets' }) -await app.listen({ port: PORT }) -console.log(`Backend running on http://localhost:${PORT}`) +await app.listen({ port: 4500, host: 'localhost' }) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 71350c5..d53f8e7 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,133 +1,110 @@ +import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; import { OAuth2Client } from "google-auth-library"; -import { eq } from "drizzle-orm"; import { uniqueNamesGenerator, adjectives, - colors, animals, } from "unique-names-generator"; -import { db } from "../db/index.ts"; -import { users } from "../db/schema.ts"; -import type { User } from "../types.ts"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; -const CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? ""; -const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? ""; -const REDIRECT_URI = - process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:4500/api/auth/callback"; -const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173"; +const client = new OAuth2Client( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + process.env.GOOGLE_REDIRECT_URI, +); -const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); +const isProd = process.env.NODE_ENV === "production"; -function generateUsername(): string { - return uniqueNamesGenerator({ - dictionaries: [adjectives, animals], - separator: " ", - length: 3, - }); -} - -export const authRouter: FastifyPluginAsync = async (app) => { - // ── Step 1: Redirect to Google ────────────────────────────── - app.get("/google", async (_req, reply) => { - const url = oauthClient.generateAuthUrl({ - access_type: "online", - // Only request openid + profile — no email scope +export const authRouter: FastifyPluginAsync = async (fastify) => { + // GET /api/auth/google — redirect to Google consent screen + fastify.get("/google", async (_req, reply) => { + const url = client.generateAuthUrl({ + access_type: "offline", scope: ["openid", "profile"], - prompt: "select_account", }); - return reply.redirect(url); + reply.redirect(url); }); - // ── Step 2: Google redirects back here ────────────────────── - app.get<{ Querystring: { code?: string; error?: string } }>( + // GET /api/auth/callback — exchange code, upsert user, set session + fastify.get<{ Querystring: { code?: string } }>( "/callback", async (req, reply) => { - const { code, error } = req.query; + const { code } = req.query; + if (!code) return reply.status(400).send({ error: "Missing code" }); - if (error || !code) { - return reply.redirect(`${FRONTEND_URL}?error=oauth_denied`); - } + const { tokens } = await client.getToken(code); + client.setCredentials(tokens); - try { - const { tokens } = await oauthClient.getToken(code); - oauthClient.setCredentials(tokens); + const ticket = await client.verifyIdToken({ + idToken: tokens.id_token!, + audience: process.env.GOOGLE_CLIENT_ID, + }); + const payload = ticket.getPayload()!; - const ticket = await oauthClient.verifyIdToken({ - idToken: tokens.id_token!, - audience: CLIENT_ID, + // Look up existing user by Google ID + let user = db + .select() + .from(users) + .where(eq(users.googleId, payload.sub)) + .get(); + + if (!user) { + let username = uniqueNamesGenerator({ + dictionaries: [adjectives, animals], + separator: " ", + length: 2, }); - const payload = ticket.getPayload(); - if (!payload?.sub) { - return reply.redirect(`${FRONTEND_URL}?error=invalid_token`); - } - const googleId = payload.sub; - const avatarUrl = payload.picture ?? null; - - // Find or create user - const existing = await db - .select() + // Collision retry — append a random suffix if username is taken + const existing = db + .select({ id: users.id }) .from(users) - .where(eq(users.googleId, googleId)) - .limit(1); + .where(eq(users.username, username)) + .get(); + if (existing) username += `-${Math.floor(Math.random() * 1000)}`; - let user: User; + const newUser = { + id: crypto.randomUUID(), + googleId: payload.sub, + username, + avatarUrl: payload.picture ?? null, + createdAt: new Date().toISOString(), + }; - if (existing[0]) { - // Returning user — refresh avatar - await db - .update(users) - .set({ avatarUrl }) - .where(eq(users.googleId, googleId)); - user = { - id: existing[0].id, - googleId: existing[0].googleId, - username: existing[0].username, - avatarUrl: avatarUrl ?? existing[0].avatarUrl, - createdAt: existing[0].createdAt, - }; - } else { - // New user — generate readable username - let username = generateUsername(); - // Retry on collision (very rare) - const collision = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - if (collision[0]) { - username = `${generateUsername()}-${Math.floor(Math.random() * 999)}`; - } + db.insert(users).values(newUser).run(); - const id = crypto.randomUUID(); - const now = new Date().toISOString(); - await db - .insert(users) - .values({ id, googleId, username, avatarUrl, createdAt: now }); - user = { id, googleId, username, avatarUrl, createdAt: now }; - } - - req.session.user = user; - return reply.redirect(FRONTEND_URL); - } catch (err) { - app.log.error(err, "OAuth callback error"); - return reply.redirect(`${FRONTEND_URL}?error=server_error`); + user = newUser; } + + req.session.user = user; + reply.redirect(process.env.FRONTEND_URL ?? "http://localhost:5173"); }, ); - // /api/auth/me - app.get("/me", async (req, reply) => { - if (!req.isAuthenticated || !req.user) { - return reply.status(401).send({ user: null }); - } - return reply.send({ user: req.user }); + // GET /api/auth/me — return current session user + fastify.get("/me", async (req, reply) => { + if (!req.isAuthenticated) + return reply.status(401).send({ error: "Unauthenticated" }); + reply.send(req.user); }); - // /api/auth/logout - app.post("/logout", async (req, reply) => { + // POST /api/auth/logout + fastify.post("/logout", async (req, reply) => { await req.session.destroy(); - return reply.send({ ok: true }); + reply.send({ ok: true }); + }); + + // GET /api/auth/csrf-token — used by the frontend to get a CSRF token + // before making state-mutating requests in production. + // In dev this route still exists but returns a no-op token. + fastify.get("/csrf-token", async (req, reply) => { + if (isProd) { + const token = reply.generateCsrf(); + return reply.send({ token }); + } + return reply.send({ token: null }); }); }; diff --git a/backend/src/routes/storageMode.ts b/backend/src/routes/storageMode.ts deleted file mode 100644 index 1854779..0000000 --- a/backend/src/routes/storageMode.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { FastifyPluginAsync } from 'fastify' - -export const storageModeRouter: FastifyPluginAsync = async (app) => { - app.get('/', async (req, reply) => { - if (!req.isAuthenticated) { - return reply.status(401).send({ storageMode: 'local' }) - } - return reply.status(200).send({ storageMode: 'remote' }) - }) -} diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 3cda0a7..0a92237 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,30 +1,63 @@ -import type { FastifyPluginAsync } from 'fastify' -import type { Ticket } from '../types.ts' +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import type { Ticket, TicketType } from "../types.ts"; -export const ticketsRouter: FastifyPluginAsync = async (app) => { - app.get('/', async (req) => req.storage.getTickets()) - - app.get<{ Params: { id: string } }>('/:id', async (req, reply) => { - const ticket = req.storage.getTicket(req.params.id) - if (!ticket) return reply.status(404).send({ error: 'Not found' }) - return ticket - }) - - app.post<{ Body: Pick }>('/', async (req, reply) => { - const { subject, description } = req.body - if (!subject?.trim()) return reply.status(400).send({ error: 'subject is required' }) - return reply.status(201).send(req.storage.createTicket({ subject, description })) - }) - - app.patch<{ Params: { id: string }; Body: Partial }>('/:id', async (req, reply) => { - const ticket = req.storage.updateTicket(req.params.id, req.body) - if (!ticket) return reply.status(404).send({ error: 'Not found' }) - return ticket - }) - - app.delete<{ Params: { id: string } }>('/:id', async (req, reply) => { - req.storage.deleteTicket(req.params.id) - return reply.status(204).send() - }) +async function requireAuth(req: FastifyRequest, reply: FastifyReply) { + if (!req.isAuthenticated) { + return reply.status(401).send({ error: "Unauthorized" }); + } } +export const ticketsRouter: FastifyPluginAsync = async (app) => { + // GET /api/tickets + app.get("/", { preHandler: requireAuth }, async (req) => { + return req.storage.getTickets(); + }); + + // GET /api/tickets/:id + app.get<{ Params: { id: string } }>( + "/:id", + { preHandler: requireAuth }, + async (req, reply) => { + const ticket = await req.storage.getTicket(req.params.id); + if (!ticket) return reply.status(404).send({ error: "Not found" }); + return ticket; + }, + ); + + // POST /api/tickets + app.post<{ + Body: { subject: string; description?: string; type?: TicketType }; + }>("/", { preHandler: requireAuth }, async (req, reply) => { + const { subject, description = "", type = "other" } = req.body; + if (!subject?.trim()) { + return reply.status(400).send({ error: "subject is required" }); + } + const ticket = await req.storage.createTicket({ + subject: subject.trim(), + description, + type, + userId: req.user?.id, + }); + return reply.status(201).send(ticket); + }); + + // PATCH /api/tickets/:id + app.patch<{ + Params: { id: string }; + Body: Partial; + }>("/:id", { preHandler: requireAuth }, async (req, reply) => { + const ticket = await req.storage.updateTicket(req.params.id, req.body); + if (!ticket) return reply.status(404).send({ error: "Not found" }); + return ticket; + }); + + // DELETE /api/tickets/:id + app.delete<{ Params: { id: string } }>( + "/:id", + { preHandler: requireAuth }, + async (req, reply) => { + await req.storage.deleteTicket(req.params.id); + return reply.status(204).send(); + }, + ); +}; diff --git a/bun.lock b/bun.lock index ad761ba..98fd114 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "dependencies": { "@fastify/cookie": "^11.0.2", "@fastify/cors": "latest", + "@fastify/csrf-protection": "^7.1.0", "@fastify/session": "^11.1.1", "drizzle-orm": "^0.45.1", "fastify": "latest", @@ -178,6 +179,10 @@ "@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="], + "@fastify/csrf": ["@fastify/csrf@8.0.1", "", {}, "sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw=="], + + "@fastify/csrf-protection": ["@fastify/csrf-protection@7.1.0", "", { "dependencies": { "@fastify/csrf": "^8.0.0", "@fastify/error": "^4.0.0", "fastify-plugin": "^5.0.0" } }, "sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9254c83..59c65d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('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 ( - + { @@ -35,7 +32,7 @@ function SupportApp() { ) } - if (authState === 'pending' || storageMode === 'pending') { + if (authState === 'pending') { return (

Loading...

@@ -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 ( setShowLogin(true)} onLogout={logout} user={user} /> + setShowLogin(true)} onLogout={logout} /> } > - {activeTab === 'tickets' && } - {activeTab === 'admin' && } + {activeTab === 'tickets' && } + {activeTab === 'admin' && } ) } diff --git a/frontend/src/components/ui/AuthBar.tsx b/frontend/src/components/ui/AuthBar.tsx index 2e13045..7f6eb50 100644 --- a/frontend/src/components/ui/AuthBar.tsx +++ b/frontend/src/components/ui/AuthBar.tsx @@ -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 ? ( - - ) : user ? ( -
-
- {/* Left: avatar + username */} -
- {user.avatarUrl ? ( - {user.username} - ) : ( -
- - {user.username[0].toUpperCase()} - -
- )} - {user.username} -
+export function AuthBar({ user, onLogin, onLogout }: AuthBarProps) { + if (!user) return - {/* Right: sign out */} - -
+ return ( +
+
+
+ {user.avatarUrl ? ( + {user.username} + ) : ( +
+ + {user.username[0].toUpperCase()} + +
+ )} + {user.username}
- ) : null} - + + +
+
) } diff --git a/frontend/src/components/ui/Layout.tsx b/frontend/src/components/ui/Layout.tsx index b9ff27e..b0723e5 100644 --- a/frontend/src/components/ui/Layout.tsx +++ b/frontend/src/components/ui/Layout.tsx @@ -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) { diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index f7e030c..17a2b39 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -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('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') }, []) diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index ee394d3..a2d08f6 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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 { + 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(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toUpperCase() + const isMutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + + const headers: Record = { + 'Content-Type': 'application/json', + ...(init.headers as Record | 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 => { +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 | 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 => - fetch('/api/tickets', { credentials: 'include' }).then(r => r.json()), +export const storage = { + async getTickets(): Promise { + try { + return await apiFetch('/api/tickets') + } catch { + return localAdapter.getTickets() + } + }, - createTicket: (data: Pick): Promise => - 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 { + try { + return await apiFetch('/api/tickets', { + method: 'POST', + body: JSON.stringify(data), + }) + } catch { + return localAdapter.createTicket(data) + } + }, - updateTicket: (id: string, patch: Partial): Promise => - 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): Promise { + try { + return await apiFetch(`/api/tickets/${id}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }) + } catch { + return localAdapter.updateTicket(id, patch) + } + }, - deleteTicket: (id: string): Promise => - 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 { + try { + await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) + return true + } catch { + return localAdapter.deleteTicket(id) + } + }, + + async getAllTickets(): Promise { + try { + return await apiFetch('/api/tickets/all') + } catch { + return localAdapter.getTickets() + } + }, } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 570d94b..e538fec 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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([]) 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) {

All tickets across the system

- {/* Stats row */}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ff3456b..bd2ecf9 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { env } from "@/env" +import { env } from "../env" interface LoginPageProps { onBack?: () => void diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index 313f010..4dab831 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -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([]) 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) => { - 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)) } diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file