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:
198
server/src/routes/auth.ts
Normal file
198
server/src/routes/auth.ts
Normal 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;
|
||||
Reference in New Issue
Block a user