Add Milestones 1 & 2: full-stack POS foundation with admin UI

- Node/Express/TypeScript API under /api/v1 with JWT auth (login, refresh, logout, /me)
- Prisma schema: vendors, users, roles, products, categories, taxes, transactions
- SQLite for local dev; Postgres via docker-compose for production
- Full CRUD routes for vendors, users, categories, taxes, products with Zod validation and RBAC
- Paginated list endpoints scoped per vendor; refresh token rotation
- React/TypeScript admin SPA (Vite): login, protected routing, sidebar layout
- Pages: Dashboard, Catalog (tabbed Products/Categories/Taxes), Users, Vendor Settings
- Shared UI: Table, Modal, FormField, Btn, PageHeader components
- Multi-stage Dockerfile; docker-compose with Postgres healthcheck
- Seed script with demo vendor and owner account
- INSTRUCTIONS.md, ROADMAP.md, .claude/launch.json for dev server config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:18:04 -05:00
parent fb62439eab
commit d53c772dd6
4594 changed files with 1876068 additions and 0 deletions

198
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Router, Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { AppError } from "../middleware/errorHandler.js";
import { requireAuth } from "../middleware/auth.js";
import { AuthenticatedRequest } from "../types/index.js";
const router = Router();
const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function signAccessToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: ACCESS_TOKEN_TTL });
}
function signRefreshToken(payload: object): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET not configured");
return jwt.sign(payload, secret, { expiresIn: REFRESH_TOKEN_TTL });
}
// POST /api/v1/auth/login
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
router.post(
"/login",
async (req: Request, res: Response, next: NextFunction) => {
try {
const body = LoginSchema.parse(req.body);
const user = await prisma.user.findUnique({
where: { email: body.email },
include: { role: true, vendor: true },
});
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password");
}
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const accessToken = signAccessToken(tokenPayload);
const refreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
},
});
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/refresh
const RefreshSchema = z.object({
refreshToken: z.string().min(1),
});
router.post(
"/refresh",
async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = RefreshSchema.parse(req.body);
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError(500, "CONFIG_ERROR", "JWT secret not configured");
let decoded: { userId: string };
try {
decoded = jwt.verify(refreshToken, secret) as { userId: string };
} catch {
throw new AppError(401, "INVALID_TOKEN", "Invalid or expired refresh token");
}
const stored = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: { include: { role: true } } },
});
if (!stored || stored.userId !== decoded.userId || stored.expiresAt < new Date()) {
throw new AppError(401, "INVALID_TOKEN", "Refresh token not found or expired");
}
// Rotate refresh token
await prisma.refreshToken.delete({ where: { id: stored.id } });
const user = stored.user;
const tokenPayload = {
userId: user.id,
vendorId: user.vendorId,
roleId: user.roleId,
roleName: user.role.name,
};
const newAccessToken = signAccessToken(tokenPayload);
const newRefreshToken = signRefreshToken({ userId: user.id });
await prisma.refreshToken.create({
data: {
token: newRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
},
});
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
} catch (err) {
next(err);
}
}
);
// POST /api/v1/auth/logout
router.post(
"/logout",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const { refreshToken } = z.object({ refreshToken: z.string().optional() }).parse(req.body);
if (refreshToken) {
await prisma.refreshToken.deleteMany({
where: { token: refreshToken, userId: authReq.auth.userId },
});
} else {
// Logout all devices
await prisma.refreshToken.deleteMany({
where: { userId: authReq.auth.userId },
});
}
res.json({ message: "Logged out successfully" });
} catch (err) {
next(err);
}
}
);
// GET /api/v1/auth/me
router.get(
"/me",
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
async (req: Request, res: Response, next: NextFunction) => {
try {
const authReq = req as AuthenticatedRequest;
const user = await prisma.user.findUnique({
where: { id: authReq.auth.userId },
include: { role: true, vendor: true },
});
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
res.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role.name,
vendorId: user.vendorId,
vendorName: user.vendor.name,
});
} catch (err) {
next(err);
}
}
);
export default router;