add:oauth
This commit is contained in:
16
backend/drizzle/0003_bitter_ink.sql
Normal file
16
backend/drizzle/0003_bitter_ink.sql
Normal file
@@ -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`);
|
||||||
164
backend/drizzle/meta/0003_snapshot.json
Normal file
164
backend/drizzle/meta/0003_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1772964242556,
|
"when": 1772964242556,
|
||||||
"tag": "0002_careful_micromax",
|
"tag": "0002_careful_micromax",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773034525409,
|
||||||
|
"tag": "0003_bitter_ink",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
|
"@fastify/csrf-protection": "^7.1.0",
|
||||||
"@fastify/session": "^11.1.1",
|
"@fastify/session": "^11.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "latest",
|
"fastify": "latest",
|
||||||
|
|||||||
@@ -31,3 +31,9 @@ export const users = sqliteTable('users', {
|
|||||||
avatarUrl: text('avatarUrl'), // Google profile picture
|
avatarUrl: text('avatarUrl'), // Google profile picture
|
||||||
createdAt: text('createdAt').notNull(),
|
createdAt: text('createdAt').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const sessions = sqliteTable('sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
data: text('data').notNull(),
|
||||||
|
expiresAt: int('expires_at').notNull(), // unix ms
|
||||||
|
})
|
||||||
|
|||||||
70
backend/src/db/sessionStore.ts
Normal file
70
backend/src/db/sessionStore.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@ import Fastify from 'fastify'
|
|||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import cookie from '@fastify/cookie'
|
import cookie from '@fastify/cookie'
|
||||||
import session from '@fastify/session'
|
import session from '@fastify/session'
|
||||||
import { authMiddleware } from './middleware/auth.ts'
|
import csrf from '@fastify/csrf-protection'
|
||||||
import { storageMiddleware } from './middleware/storage.ts'
|
|
||||||
import { storageModeRouter } from './routes/storageMode.ts'
|
import { authMiddleware } from './middleware/auth.js'
|
||||||
import { ticketsRouter } from './routes/tickets.ts'
|
import { storageMiddleware } from './middleware/storage.js'
|
||||||
import { authRouter } from './routes/auth.ts'
|
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 app = Fastify({ logger: true })
|
||||||
const PORT = Number(process.env.PORT) || 4500
|
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
|
||||||
@@ -19,20 +22,27 @@ await app.register(cors, {
|
|||||||
await app.register(cookie)
|
await app.register(cookie)
|
||||||
|
|
||||||
await app.register(session, {
|
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: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
httpOnly: true,
|
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(authMiddleware)
|
||||||
await app.register(storageMiddleware)
|
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 })
|
await app.listen({ port: 4500, host: 'localhost' })
|
||||||
console.log(`Backend running on http://localhost:${PORT}`)
|
|
||||||
|
|||||||
@@ -1,133 +1,110 @@
|
|||||||
|
import fp from "fastify-plugin";
|
||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
import {
|
||||||
uniqueNamesGenerator,
|
uniqueNamesGenerator,
|
||||||
adjectives,
|
adjectives,
|
||||||
colors,
|
|
||||||
animals,
|
animals,
|
||||||
} from "unique-names-generator";
|
} from "unique-names-generator";
|
||||||
import { db } from "../db/index.ts";
|
import { eq } from "drizzle-orm";
|
||||||
import { users } from "../db/schema.ts";
|
import { db } from "../db/index.js";
|
||||||
import type { User } from "../types.ts";
|
import { users } from "../db/schema.js";
|
||||||
|
|
||||||
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? "";
|
const client = new OAuth2Client(
|
||||||
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? "";
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
const REDIRECT_URI =
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:4500/api/auth/callback";
|
process.env.GOOGLE_REDIRECT_URI,
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173";
|
);
|
||||||
|
|
||||||
const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
function generateUsername(): string {
|
export const authRouter: FastifyPluginAsync = async (fastify) => {
|
||||||
return uniqueNamesGenerator({
|
// GET /api/auth/google — redirect to Google consent screen
|
||||||
dictionaries: [adjectives, animals],
|
fastify.get("/google", async (_req, reply) => {
|
||||||
separator: " ",
|
const url = client.generateAuthUrl({
|
||||||
length: 3,
|
access_type: "offline",
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
scope: ["openid", "profile"],
|
scope: ["openid", "profile"],
|
||||||
prompt: "select_account",
|
|
||||||
});
|
});
|
||||||
return reply.redirect(url);
|
reply.redirect(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Step 2: Google redirects back here ──────────────────────
|
// GET /api/auth/callback — exchange code, upsert user, set session
|
||||||
app.get<{ Querystring: { code?: string; error?: string } }>(
|
fastify.get<{ Querystring: { code?: string } }>(
|
||||||
"/callback",
|
"/callback",
|
||||||
async (req, reply) => {
|
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) {
|
const { tokens } = await client.getToken(code);
|
||||||
return reply.redirect(`${FRONTEND_URL}?error=oauth_denied`);
|
client.setCredentials(tokens);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const ticket = await client.verifyIdToken({
|
||||||
const { tokens } = await oauthClient.getToken(code);
|
|
||||||
oauthClient.setCredentials(tokens);
|
|
||||||
|
|
||||||
const ticket = await oauthClient.verifyIdToken({
|
|
||||||
idToken: tokens.id_token!,
|
idToken: tokens.id_token!,
|
||||||
audience: CLIENT_ID,
|
audience: process.env.GOOGLE_CLIENT_ID,
|
||||||
});
|
});
|
||||||
const payload = ticket.getPayload();
|
const payload = ticket.getPayload()!;
|
||||||
if (!payload?.sub) {
|
|
||||||
return reply.redirect(`${FRONTEND_URL}?error=invalid_token`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleId = payload.sub;
|
// Look up existing user by Google ID
|
||||||
const avatarUrl = payload.picture ?? null;
|
let user = db
|
||||||
|
|
||||||
// Find or create user
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.googleId, googleId))
|
.where(eq(users.googleId, payload.sub))
|
||||||
.limit(1);
|
.get();
|
||||||
|
|
||||||
let user: User;
|
if (!user) {
|
||||||
|
let username = uniqueNamesGenerator({
|
||||||
|
dictionaries: [adjectives, animals],
|
||||||
|
separator: " ",
|
||||||
|
length: 2,
|
||||||
|
});
|
||||||
|
|
||||||
if (existing[0]) {
|
// Collision retry — append a random suffix if username is taken
|
||||||
// Returning user — refresh avatar
|
const existing = db
|
||||||
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 })
|
.select({ id: users.id })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.username, username))
|
.where(eq(users.username, username))
|
||||||
.limit(1);
|
.get();
|
||||||
if (collision[0]) {
|
if (existing) username += `-${Math.floor(Math.random() * 1000)}`;
|
||||||
username = `${generateUsername()}-${Math.floor(Math.random() * 999)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const newUser = {
|
||||||
const now = new Date().toISOString();
|
id: crypto.randomUUID(),
|
||||||
await db
|
googleId: payload.sub,
|
||||||
.insert(users)
|
username,
|
||||||
.values({ id, googleId, username, avatarUrl, createdAt: now });
|
avatarUrl: payload.picture ?? null,
|
||||||
user = { id, googleId, username, avatarUrl, createdAt: now };
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
db.insert(users).values(newUser).run();
|
||||||
|
|
||||||
|
user = newUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
return reply.redirect(FRONTEND_URL);
|
reply.redirect(process.env.FRONTEND_URL ?? "http://localhost:5173");
|
||||||
} catch (err) {
|
|
||||||
app.log.error(err, "OAuth callback error");
|
|
||||||
return reply.redirect(`${FRONTEND_URL}?error=server_error`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// /api/auth/me
|
// GET /api/auth/me — return current session user
|
||||||
app.get("/me", async (req, reply) => {
|
fastify.get("/me", async (req, reply) => {
|
||||||
if (!req.isAuthenticated || !req.user) {
|
if (!req.isAuthenticated)
|
||||||
return reply.status(401).send({ user: null });
|
return reply.status(401).send({ error: "Unauthenticated" });
|
||||||
}
|
reply.send(req.user);
|
||||||
return reply.send({ user: req.user });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// /api/auth/logout
|
// POST /api/auth/logout
|
||||||
app.post("/logout", async (req, reply) => {
|
fastify.post("/logout", async (req, reply) => {
|
||||||
await req.session.destroy();
|
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 });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,63 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type { Ticket } from '../types.ts'
|
import type { Ticket, TicketType } from "../types.ts";
|
||||||
|
|
||||||
export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
app.get('/', async (req) => req.storage.getTickets())
|
if (!req.isAuthenticated) {
|
||||||
|
return reply.status(401).send({ error: "Unauthorized" });
|
||||||
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<Ticket, 'subject' | 'description'> }>('/', 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<Ticket> }>('/: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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Ticket>;
|
||||||
|
}>("/: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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
5
bun.lock
5
bun.lock
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "latest",
|
||||||
|
"@fastify/csrf-protection": "^7.1.0",
|
||||||
"@fastify/session": "^11.1.1",
|
"@fastify/session": "^11.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "latest",
|
"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/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/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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Tabs } from './components/ui/Tabs.tsx'
|
|||||||
import { UserPage } from './pages/UserPage.tsx'
|
import { UserPage } from './pages/UserPage.tsx'
|
||||||
import { AdminPage } from './pages/AdminPage.tsx'
|
import { AdminPage } from './pages/AdminPage.tsx'
|
||||||
import { LoginPage } from './pages/LoginPage.tsx'
|
import { LoginPage } from './pages/LoginPage.tsx'
|
||||||
import { useStorageMode } from './hooks/useStorageMode.ts'
|
|
||||||
import { useAuth } from './hooks/useAuth.ts'
|
import { useAuth } from './hooks/useAuth.ts'
|
||||||
import { AuthBar } from './components/ui/AuthBar.tsx'
|
import { AuthBar } from './components/ui/AuthBar.tsx'
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
@@ -16,14 +15,12 @@ function SupportApp() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
|
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
|
||||||
const [showLogin, setShowLogin] = useState(false)
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
const { user, authState, logout } = useAuth()
|
const { user, authState, logout } = useAuth()
|
||||||
const storageMode = useStorageMode()
|
|
||||||
|
|
||||||
const urlError = new URLSearchParams(window.location.search).get('error')
|
const urlError = new URLSearchParams(window.location.search).get('error')
|
||||||
|
|
||||||
if (showLogin || urlError) {
|
if (showLogin || urlError) {
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout>
|
||||||
user={user}>
|
|
||||||
<LoginPage
|
<LoginPage
|
||||||
error={urlError}
|
error={urlError}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
@@ -35,7 +32,7 @@ function SupportApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authState === 'pending' || storageMode === 'pending') {
|
if (authState === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-bg-100">
|
<div className="flex min-h-screen items-center justify-center bg-bg-100">
|
||||||
<p className="text-sm text-fg-300">Loading...</p>
|
<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 }[] = [
|
const tabs: { value: TabValue; label: string }[] = [
|
||||||
{
|
{
|
||||||
value: 'tickets',
|
value: 'tickets',
|
||||||
label: user ? `${user.username}'s Tickets` : 'My Tickets',
|
label: 'My Tickets',
|
||||||
},
|
},
|
||||||
{ value: 'admin', label: 'Admin' },
|
{ value: 'admin', label: 'Admin' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
user={user}
|
|
||||||
subHeader={
|
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} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
{activeTab === 'tickets' && <UserPage storageMode={storageMode} />}
|
{activeTab === 'tickets' && <UserPage />}
|
||||||
{activeTab === 'admin' && <AdminPage storageMode={storageMode} />}
|
{activeTab === 'admin' && <AdminPage />}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,17 @@ import type { User } from "../../lib/types"
|
|||||||
import { GuestBanner } from "./GuestBanner"
|
import { GuestBanner } from "./GuestBanner"
|
||||||
|
|
||||||
interface AuthBarProps {
|
interface AuthBarProps {
|
||||||
isGuest: boolean
|
user: User | null
|
||||||
onLogin: () => void
|
onLogin: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
user?: User | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthBar({ isGuest, onLogin, onLogout, user }: AuthBarProps) {
|
export function AuthBar({ user, onLogin, onLogout }: AuthBarProps) {
|
||||||
|
if (!user) return <GuestBanner onLogin={onLogin} />
|
||||||
|
|
||||||
return (
|
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="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="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">
|
<div className="flex items-center gap-2">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -35,7 +30,6 @@ export function AuthBar({ isGuest, onLogin, onLogout, user }: AuthBarProps) {
|
|||||||
<span className="text-xs text-fg-200">{user.username}</span>
|
<span className="text-xs text-fg-200">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: sign out */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
|
className="text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
|
||||||
@@ -44,7 +38,5 @@ export function AuthBar({ isGuest, onLogin, onLogout, user }: AuthBarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { User } from '../../lib/types.ts'
|
|
||||||
import { Navbar } from './Navbar.tsx'
|
import { Navbar } from './Navbar.tsx'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
subHeader?: React.ReactNode
|
subHeader?: React.ReactNode
|
||||||
user?: User | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ children, subHeader }: LayoutProps) {
|
export function Layout({ children, subHeader }: LayoutProps) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import type { User } from '../lib/types.ts'
|
import type { User } from '../lib/types.ts'
|
||||||
|
import { env } from '../env.ts'
|
||||||
|
|
||||||
export type AuthState = 'pending' | 'authenticated' | 'unauthenticated'
|
export type AuthState = 'pending' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
@@ -8,16 +9,14 @@ export function useAuth() {
|
|||||||
const [authState, setAuthState] = useState<AuthState>('pending')
|
const [authState, setAuthState] = useState<AuthState>('pending')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me', { credentials: 'include' })
|
fetch(`${env.apiUrl}/api/auth/me`, { credentials: 'include' })
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => {
|
if (!res.ok) throw new Error('unauthenticated')
|
||||||
if (data.user) {
|
return res.json()
|
||||||
setUser(data.user)
|
})
|
||||||
|
.then((data: User) => {
|
||||||
|
setUser(data)
|
||||||
setAuthState('authenticated')
|
setAuthState('authenticated')
|
||||||
} else {
|
|
||||||
setUser(null)
|
|
||||||
setAuthState('unauthenticated')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
@@ -26,7 +25,7 @@ export function useAuth() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
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)
|
setUser(null)
|
||||||
setAuthState('unauthenticated')
|
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 {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(KEY) ?? '[]')
|
return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]')
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function save(tickets: Ticket[]): void {
|
function localSet(tickets: Ticket[]) {
|
||||||
localStorage.setItem(KEY, JSON.stringify(tickets))
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets))
|
||||||
}
|
}
|
||||||
|
|
||||||
const local = {
|
export const localAdapter = {
|
||||||
getTickets: (): Ticket[] => load(),
|
getTickets: (): Ticket[] => localGet(),
|
||||||
|
createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => {
|
||||||
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket => {
|
|
||||||
const ticket: Ticket = {
|
const ticket: Ticket = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
userId: null,
|
userId: null,
|
||||||
@@ -31,49 +83,72 @@ const local = {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
save([ticket, ...load()])
|
localSet([...localGet(), ticket])
|
||||||
return ticket
|
return ticket
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
updateTicket: (id: string, patch: Partial<Ticket>): Ticket | null => {
|
||||||
const tickets = load().map(t => t.id === id ? { ...t, ...patch } : t)
|
const tickets = localGet()
|
||||||
save(tickets)
|
const idx = tickets.findIndex((t) => t.id === id)
|
||||||
return tickets.find(t => t.id === id) ?? null
|
if (idx === -1) return null
|
||||||
|
tickets[idx] = { ...tickets[idx], ...patch }
|
||||||
|
localSet(tickets)
|
||||||
|
return tickets[idx]
|
||||||
},
|
},
|
||||||
|
deleteTicket: (id: string): boolean => {
|
||||||
deleteTicket: (id: string): void => {
|
const before = localGet()
|
||||||
save(load().filter(t => t.id !== id))
|
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 = {
|
export const storage = {
|
||||||
getTickets: (): Promise<Ticket[]> =>
|
async getTickets(): Promise<Ticket[]> {
|
||||||
fetch('/api/tickets', { credentials: 'include' }).then(r => r.json()),
|
try {
|
||||||
|
return await apiFetch<Ticket[]>('/api/tickets')
|
||||||
|
} catch {
|
||||||
|
return localAdapter.getTickets()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Promise<Ticket> =>
|
async createTicket(data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||||
fetch('/api/tickets', {
|
try {
|
||||||
|
return await apiFetch<Ticket>('/api/tickets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}).then(r => r.json()),
|
})
|
||||||
|
} catch {
|
||||||
|
return localAdapter.createTicket(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
updateTicket: (id: string, patch: Partial<Ticket>): Promise<Ticket> =>
|
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
||||||
fetch(`/api/tickets/${id}`, {
|
try {
|
||||||
|
return await apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
}).then(r => r.json()),
|
})
|
||||||
|
} catch {
|
||||||
|
return localAdapter.updateTicket(id, patch)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deleteTicket: (id: string): Promise<void> =>
|
async deleteTicket(id: string): Promise<boolean> {
|
||||||
fetch(`/api/tickets/${id}`, { method: 'DELETE', credentials: 'include' }).then(() => undefined),
|
try {
|
||||||
}
|
await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' })
|
||||||
|
return true
|
||||||
// ─── Resolver ─────────────────────────────────────────────────
|
} catch {
|
||||||
|
return localAdapter.deleteTicket(id)
|
||||||
export function getStorage(mode: StorageMode) {
|
}
|
||||||
return mode === 'remote' ? remote : local
|
},
|
||||||
|
|
||||||
|
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 { useState, useEffect } from 'react'
|
||||||
import { AdminTable } from '../components/admin/AdminTable.tsx'
|
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'
|
import type { Ticket } from '../lib/types.ts'
|
||||||
|
|
||||||
interface AdminPageProps {
|
|
||||||
storageMode: 'local' | 'remote'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
@@ -21,18 +17,12 @@ function StatCard({ label, value }: StatCardProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPage({ storageMode }: AdminPageProps) {
|
export function AdminPage() {
|
||||||
const storage = getStorage(storageMode)
|
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const result = storage.getTickets()
|
storage.getTickets().then(setTickets)
|
||||||
if (result instanceof Promise) {
|
}, [])
|
||||||
result.then(setTickets)
|
|
||||||
} else {
|
|
||||||
setTickets(result)
|
|
||||||
}
|
|
||||||
}, [storageMode])
|
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tickets.length,
|
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>
|
<p className="mt-0.5 text-sm text-fg-300">All tickets across the system</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="mb-6 grid grid-cols-4 gap-3">
|
<div className="mb-6 grid grid-cols-4 gap-3">
|
||||||
<StatCard label="Total" value={stats.total} />
|
<StatCard label="Total" value={stats.total} />
|
||||||
<StatCard label="Open" value={stats.open} />
|
<StatCard label="Open" value={stats.open} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { env } from "@/env"
|
import { env } from "../env"
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
|||||||
@@ -4,37 +4,25 @@ import { Button } from '../components/ui/Button.tsx'
|
|||||||
import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
import { TicketTable } from '../components/tickets/TicketTable.tsx'
|
||||||
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
|
||||||
import { useModal } from '../hooks/useModal.ts'
|
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'
|
import type { Ticket } from '../lib/types.ts'
|
||||||
|
|
||||||
interface UserPageProps {
|
export function UserPage() {
|
||||||
storageMode: 'local' | 'remote'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserPage({ storageMode }: UserPageProps) {
|
|
||||||
const storage = getStorage(storageMode)
|
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const newTicketModal = useModal()
|
const newTicketModal = useModal()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const result = storage.getTickets()
|
storage.getTickets().then(setTickets)
|
||||||
if (result instanceof Promise) {
|
}, [])
|
||||||
result.then(setTickets)
|
|
||||||
} else {
|
|
||||||
setTickets(result)
|
|
||||||
}
|
|
||||||
}, [storageMode])
|
|
||||||
|
|
||||||
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
|
||||||
const result = storage.createTicket(form)
|
const ticket = await storage.createTicket(form)
|
||||||
const ticket = result instanceof Promise ? await result : result
|
|
||||||
setTickets(prev => [ticket, ...prev])
|
setTickets(prev => [ticket, ...prev])
|
||||||
newTicketModal.close()
|
newTicketModal.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
const result = storage.deleteTicket(id)
|
await storage.deleteTicket(id)
|
||||||
if (result instanceof Promise) await result
|
|
||||||
setTickets(prev => prev.filter(t => t.id !== id))
|
setTickets(prev => prev.filter(t => t.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user