- Backend: Node.js + Express + TypeScript + Prisma + PostgreSQL - Frontend: React + TypeScript + Tailwind CSS + Vite - Multi-tenant with role-based access (SystemAdmin, Technician, Customer) - Workflow editor with drag-and-drop steps - JWT authentication with refresh tokens - German UI throughout
188 lines
5.0 KiB
TypeScript
188 lines
5.0 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import { z } from 'zod';
|
|
import prisma from '../lib/prisma';
|
|
import { authenticate } from '../middleware/auth';
|
|
import { AppError } from '../lib/errors';
|
|
|
|
const router = Router();
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too';
|
|
const ACCESS_TOKEN_EXPIRY = '15m';
|
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
|
|
|
// Store for invalidated refresh tokens (in production, use Redis)
|
|
const invalidatedTokens = new Set<string>();
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email('Invalid email format'),
|
|
password: z.string().min(1, 'Password is required'),
|
|
});
|
|
|
|
const refreshSchema = z.object({
|
|
refreshToken: z.string().min(1, 'Refresh token is required'),
|
|
});
|
|
|
|
function generateTokens(user: { id: string; email: string; role: string; tenantId: string | null; firstName: string; lastName: string }) {
|
|
const payload = {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
tenantId: user.tenantId,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
};
|
|
|
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
|
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
// POST /api/auth/login
|
|
router.post('/login', async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const parsed = loginSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
error: 'Validation error',
|
|
details: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { email, password } = parsed.data;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email },
|
|
include: { tenant: true },
|
|
});
|
|
|
|
if (!user || !user.active) {
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
return;
|
|
}
|
|
|
|
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
|
if (!validPassword) {
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
return;
|
|
}
|
|
|
|
const tokens = generateTokens(user);
|
|
|
|
res.json({
|
|
...tokens,
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
role: user.role,
|
|
tenantId: user.tenantId,
|
|
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug } : null,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/refresh
|
|
router.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const parsed = refreshSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
error: 'Validation error',
|
|
details: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { refreshToken } = parsed.data;
|
|
|
|
if (invalidatedTokens.has(refreshToken)) {
|
|
res.status(401).json({ error: 'Token has been invalidated' });
|
|
return;
|
|
}
|
|
|
|
let decoded: any;
|
|
try {
|
|
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
|
|
} catch {
|
|
res.status(401).json({ error: 'Invalid refresh token' });
|
|
return;
|
|
}
|
|
|
|
if (decoded.type !== 'refresh') {
|
|
res.status(401).json({ error: 'Invalid token type' });
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: decoded.id },
|
|
});
|
|
|
|
if (!user || !user.active) {
|
|
res.status(401).json({ error: 'User not found or inactive' });
|
|
return;
|
|
}
|
|
|
|
// Invalidate old refresh token
|
|
invalidatedTokens.add(refreshToken);
|
|
|
|
const tokens = generateTokens(user);
|
|
|
|
res.json(tokens);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/auth/logout
|
|
router.post('/logout', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const refreshToken = req.body.refreshToken;
|
|
if (refreshToken) {
|
|
invalidatedTokens.add(refreshToken);
|
|
}
|
|
res.json({ message: 'Logged out successfully' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/auth/me
|
|
router.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.id },
|
|
include: { tenant: true },
|
|
});
|
|
|
|
if (!user) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
role: user.role,
|
|
tenantId: user.tenantId,
|
|
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug, logo: user.tenant.logo } : null,
|
|
createdAt: user.createdAt,
|
|
updatedAt: user.updatedAt,
|
|
});
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|