commit d58d53fa3b2168a17f02e7e21e14cc8428046eba Author: root Date: Fri Mar 20 22:48:04 2026 +0000 feat: initial workflow portal - multi-tenant architecture - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e961762 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +.env +*.log +.DS_Store +coverage/ +.nyc_output/ +*.pem +*.key diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..be8c8be --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal +JWT_SECRET=change-me-in-production +JWT_REFRESH_SECRET=change-me-too +PORT=4000 +CORS_ORIGIN=http://localhost:3000 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8a6488f --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.js.map diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..29418ce --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "workflow-portal-backend", + "version": "1.0.0", + "scripts": { + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "db:migrate": "npx prisma migrate deploy", + "db:seed": "ts-node prisma/seed.ts", + "db:generate": "npx prisma generate" + }, + "dependencies": { + "@prisma/client": "^6.0.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.2", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.0.0", + "nodemon": "^3.1.0", + "prisma": "^6.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.0" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..cc93149 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,108 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Tenant { + id String @id @default(uuid()) + name String + slug String @unique + logo String? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + users User[] + workflows Workflow[] + categories Category[] +} + +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + firstName String + lastName String + role Role + tenantId String? + tenant Tenant? @relation(fields: [tenantId], references: [id]) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdWorkflows Workflow[] @relation("CreatedBy") + updatedWorkflows Workflow[] @relation("UpdatedBy") +} + +enum Role { + SYSTEM_ADMIN + TECHNICIAN + CUSTOMER_ADMIN + CUSTOMER_USER +} + +model Category { + id String @id @default(uuid()) + name String + icon String? + tenantId String? + tenant Tenant? @relation(fields: [tenantId], references: [id]) + workflows Workflow[] + createdAt DateTime @default(now()) +} + +model Workflow { + id String @id @default(uuid()) + title String + description String? + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + categoryId String? + category Category? @relation(fields: [categoryId], references: [id]) + status WorkflowStatus @default(DRAFT) + version Int @default(1) + isTemplate Boolean @default(false) + tags String[] + createdById String + createdBy User @relation("CreatedBy", fields: [createdById], references: [id]) + updatedById String? + updatedBy User? @relation("UpdatedBy", fields: [updatedById], references: [id]) + steps WorkflowStep[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([tenantId]) + @@index([status]) +} + +enum WorkflowStatus { + DRAFT + REVIEW + PUBLISHED + ARCHIVED +} + +model WorkflowStep { + id String @id @default(uuid()) + workflowId String + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + order Int + title String + content String + type StepType @default(INSTRUCTION) + isRequired Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([workflowId]) +} + +enum StepType { + INSTRUCTION + CHECKLIST + WARNING + INFO + COMMAND +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..0575c40 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,212 @@ +import { PrismaClient, Role, WorkflowStatus, StepType } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Seeding database...'); + + // Create default tenant "Systemhaus" + const systemhausTenant = await prisma.tenant.upsert({ + where: { slug: 'systemhaus' }, + update: {}, + create: { + name: 'Systemhaus', + slug: 'systemhaus', + active: true, + }, + }); + console.log('Created tenant:', systemhausTenant.name); + + // Create sample tenant "Musterkunde GmbH" + const musterkundeTenant = await prisma.tenant.upsert({ + where: { slug: 'musterkunde' }, + update: {}, + create: { + name: 'Musterkunde GmbH', + slug: 'musterkunde', + active: true, + }, + }); + console.log('Created tenant:', musterkundeTenant.name); + + // Create system admin user + const adminPasswordHash = await bcrypt.hash('admin123', 12); + const systemAdmin = await prisma.user.upsert({ + where: { email: 'admin@systemhaus.de' }, + update: {}, + create: { + email: 'admin@systemhaus.de', + passwordHash: adminPasswordHash, + firstName: 'System', + lastName: 'Administrator', + role: Role.SYSTEM_ADMIN, + tenantId: systemhausTenant.id, + active: true, + }, + }); + console.log('Created user:', systemAdmin.email, '(SYSTEM_ADMIN)'); + + // Create technician user + const technicianPasswordHash = await bcrypt.hash('admin123', 12); + const technician = await prisma.user.upsert({ + where: { email: 'techniker@systemhaus.de' }, + update: {}, + create: { + email: 'techniker@systemhaus.de', + passwordHash: technicianPasswordHash, + firstName: 'Max', + lastName: 'Techniker', + role: Role.TECHNICIAN, + tenantId: systemhausTenant.id, + active: true, + }, + }); + console.log('Created user:', technician.email, '(TECHNICIAN)'); + + // Create customer admin user + const customerAdminPasswordHash = await bcrypt.hash('admin123', 12); + const customerAdmin = await prisma.user.upsert({ + where: { email: 'admin@musterkunde.de' }, + update: {}, + create: { + email: 'admin@musterkunde.de', + passwordHash: customerAdminPasswordHash, + firstName: 'Maria', + lastName: 'Mustermann', + role: Role.CUSTOMER_ADMIN, + tenantId: musterkundeTenant.id, + active: true, + }, + }); + console.log('Created user:', customerAdmin.email, '(CUSTOMER_ADMIN)'); + + // Create customer user + const customerUserPasswordHash = await bcrypt.hash('admin123', 12); + const customerUser = await prisma.user.upsert({ + where: { email: 'user@musterkunde.de' }, + update: {}, + create: { + email: 'user@musterkunde.de', + passwordHash: customerUserPasswordHash, + firstName: 'Hans', + lastName: 'Benutzer', + role: Role.CUSTOMER_USER, + tenantId: musterkundeTenant.id, + active: true, + }, + }); + console.log('Created user:', customerUser.email, '(CUSTOMER_USER)'); + + // Create categories (global, no tenant) + const categories = [ + { name: 'Benutzerverwaltung', icon: 'users' }, + { name: 'Netzwerk', icon: 'network' }, + { name: 'VPN', icon: 'shield' }, + { name: 'Backup', icon: 'database' }, + { name: 'Server', icon: 'server' }, + ]; + + const createdCategories: Record = {}; + for (const cat of categories) { + const existing = await prisma.category.findFirst({ + where: { name: cat.name, tenantId: null }, + }); + if (existing) { + createdCategories[cat.name] = existing.id; + console.log('Category already exists:', cat.name); + } else { + const created = await prisma.category.create({ + data: { + name: cat.name, + icon: cat.icon, + tenantId: null, + }, + }); + createdCategories[cat.name] = created.id; + console.log('Created category:', cat.name); + } + } + + // Create sample workflow: "Neuen Benutzer anlegen" + const existingWorkflow = await prisma.workflow.findFirst({ + where: { + title: 'Neuen Benutzer anlegen', + tenantId: musterkundeTenant.id, + }, + }); + + if (!existingWorkflow) { + const workflow = await prisma.workflow.create({ + data: { + title: 'Neuen Benutzer anlegen', + description: 'Schritt-fuer-Schritt Anleitung zum Anlegen eines neuen Benutzers im Active Directory und allen relevanten Systemen.', + tenantId: musterkundeTenant.id, + categoryId: createdCategories['Benutzerverwaltung'], + status: WorkflowStatus.PUBLISHED, + version: 1, + isTemplate: false, + tags: ['benutzer', 'onboarding', 'active-directory'], + createdById: systemAdmin.id, + steps: { + create: [ + { + order: 0, + title: 'Anforderung pruefen', + content: 'Pruefen Sie die Benutzeranforderung auf Vollstaendigkeit:\n- Vor- und Nachname\n- Abteilung\n- Vorgesetzter\n- Benoetigte Berechtigungen\n- Startdatum', + type: StepType.CHECKLIST, + isRequired: true, + }, + { + order: 1, + title: 'Active Directory Benutzer erstellen', + content: 'Oeffnen Sie die Active Directory Benutzer und Computer Konsole und erstellen Sie einen neuen Benutzer:\n\n```powershell\nNew-ADUser -Name "Vorname Nachname" -GivenName "Vorname" -Surname "Nachname" -SamAccountName "v.nachname" -UserPrincipalName "v.nachname@domain.de" -Path "OU=Users,DC=domain,DC=de" -AccountPassword (ConvertTo-SecureString "TempPasswort123!" -AsPlainText -Force) -Enabled $true -ChangePasswordAtLogon $true\n```', + type: StepType.COMMAND, + isRequired: true, + }, + { + order: 2, + title: 'Gruppenmitgliedschaften konfigurieren', + content: 'Fuegen Sie den Benutzer den entsprechenden Sicherheitsgruppen hinzu basierend auf Abteilung und Rolle:\n- Abteilungsgruppe\n- VPN-Zugang (falls erforderlich)\n- Drucker-Gruppen\n- Anwendungs-Gruppen', + type: StepType.INSTRUCTION, + isRequired: true, + }, + { + order: 3, + title: 'E-Mail Postfach einrichten', + content: 'WICHTIG: Warten Sie nach der AD-Erstellung mindestens 15 Minuten auf die Exchange-Synchronisation. Pruefen Sie anschliessend, ob das Postfach automatisch erstellt wurde. Falls nicht, erstellen Sie es manuell im Exchange Admin Center.', + type: StepType.WARNING, + isRequired: true, + }, + { + order: 4, + title: 'Dokumentation und Uebergabe', + content: 'Dokumentieren Sie die Zugangsdaten und informieren Sie den Vorgesetzten:\n- Benutzername\n- Initiales Passwort (sicher uebermitteln!)\n- E-Mail Adresse\n- VPN-Zugangsdaten (falls zutreffend)\n\nHinweis: Verwenden Sie einen sicheren Kanal fuer die Passwort-Uebermittlung.', + type: StepType.INFO, + isRequired: true, + }, + ], + }, + }, + }); + console.log('Created workflow:', workflow.title, 'with 5 steps'); + } else { + console.log('Workflow already exists:', existingWorkflow.title); + } + + console.log('\nSeed complete!'); + console.log('\nTest Accounts:'); + console.log(' System Admin: admin@systemhaus.de / admin123'); + console.log(' Technician: techniker@systemhaus.de / admin123'); + console.log(' Customer Admin: admin@musterkunde.de / admin123'); + console.log(' Customer User: user@musterkunde.de / admin123'); +} + +main() + .catch((e) => { + console.error('Seed error:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..1511da0 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,108 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import express, { Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import { AppError } from './lib/errors'; + +import authRoutes from './routes/auth.routes'; +import tenantRoutes from './routes/tenant.routes'; +import userRoutes from './routes/user.routes'; +import workflowRoutes from './routes/workflow.routes'; +import categoryRoutes from './routes/category.routes'; + +const app = express(); +const PORT = parseInt(process.env.PORT || '4000', 10); + +// Security middleware +app.use(helmet()); + +// CORS +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + credentials: true, +})); + +// Body parsing +app.use(express.json({ limit: '10mb' })); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests', message: 'Please try again later' }, +}); +app.use('/api/', limiter); + +// Stricter rate limit for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many login attempts', message: 'Please try again later' }, +}); +app.use('/api/auth/login', authLimiter); +app.use('/api/auth/refresh', authLimiter); + +// Health check +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Routes +app.use('/api/auth', authRoutes); +app.use('/api/tenants', tenantRoutes); +app.use('/api/users', userRoutes); +app.use('/api/workflows', workflowRoutes); +app.use('/api/categories', categoryRoutes); + +// 404 handler +app.use((_req: Request, res: Response) => { + res.status(404).json({ error: 'Not found', message: 'The requested endpoint does not exist' }); +}); + +// Global error handler +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Error:', err); + + if (err instanceof AppError) { + res.status(err.statusCode).json({ + error: err.message, + ...(err.statusCode >= 500 ? {} : { message: err.message }), + }); + return; + } + + // Prisma known errors + if ((err as any).code === 'P2002') { + res.status(409).json({ + error: 'Conflict', + message: 'A record with this unique constraint already exists', + }); + return; + } + + if ((err as any).code === 'P2025') { + res.status(404).json({ + error: 'Not found', + message: 'The requested record was not found', + }); + return; + } + + res.status(500).json({ + error: 'Internal server error', + message: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred', + }); +}); + +app.listen(PORT, () => { + console.log(`Workflow Portal API running on port ${PORT}`); +}); + +export default app; diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 0000000..4c20961 --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,35 @@ +export class AppError extends Error { + public statusCode: number; + public isOperational: boolean; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + Object.setPrototypeOf(this, AppError.prototype); + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found`, 404); + } +} + +export class ValidationError extends AppError { + constructor(message: string) { + super(message, 400); + } +} + +export class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 401); + } +} + +export class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403); + } +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..4e54f7a --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..a13b690 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { Role } from '@prisma/client'; + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; + +interface JwtPayload { + id: string; + email: string; + role: Role; + tenantId: string | null; + firstName: string; + lastName: string; +} + +export function authenticate(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Authentication required', message: 'No valid token provided' }); + return; + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; + req.user = { + id: decoded.id, + email: decoded.email, + role: decoded.role, + tenantId: decoded.tenantId, + firstName: decoded.firstName, + lastName: decoded.lastName, + }; + next(); + } catch (err) { + if (err instanceof jwt.TokenExpiredError) { + res.status(401).json({ error: 'Token expired', message: 'Please refresh your token' }); + return; + } + res.status(401).json({ error: 'Invalid token', message: 'Token verification failed' }); + } +} + +export function authorize(...roles: Role[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!roles.includes(req.user.role)) { + res.status(403).json({ + error: 'Forbidden', + message: `Role ${req.user.role} is not authorized for this action`, + }); + return; + } + + next(); + }; +} + +export function tenantGuard(req: Request, res: Response, next: NextFunction): void { + if (!req.user) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + // SYSTEM_ADMIN and TECHNICIAN can access all tenants + if (req.user.role === 'SYSTEM_ADMIN' || req.user.role === 'TECHNICIAN') { + next(); + return; + } + + // For tenant-scoped users, check that the requested tenantId matches their own + const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId; + + if (requestedTenantId && requestedTenantId !== req.user.tenantId) { + res.status(403).json({ + error: 'Forbidden', + message: 'You can only access data within your own tenant', + }); + return; + } + + next(); +} + +export function getTenantFilter(user: JwtPayload): { tenantId: string } | {} { + if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') { + return {}; + } + return { tenantId: user.tenantId! }; +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..abc4fdd --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -0,0 +1,187 @@ +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(); + +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; diff --git a/backend/src/routes/category.routes.ts b/backend/src/routes/category.routes.ts new file mode 100644 index 0000000..dd5f4f4 --- /dev/null +++ b/backend/src/routes/category.routes.ts @@ -0,0 +1,223 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import prisma from '../lib/prisma'; +import { authenticate, authorize } from '../middleware/auth'; + +const router = Router(); + +const createCategorySchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + icon: z.string().max(100).optional().nullable(), + tenantId: z.string().uuid().optional().nullable(), +}); + +const updateCategorySchema = z.object({ + name: z.string().min(1).max(255).optional(), + icon: z.string().max(100).optional().nullable(), +}); + +router.use(authenticate); + +// GET /api/categories +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + let where: any = {}; + + if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') { + // Customer users see global categories (tenantId is null) + their tenant's categories + where = { + OR: [ + { tenantId: null }, + { tenantId: user.tenantId }, + ], + }; + } + + // Allow filtering by tenantId for system users + if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) { + where = { + OR: [ + { tenantId: null }, + { tenantId: req.query.tenantId as string }, + ], + }; + } + + const categories = await prisma.category.findMany({ + where, + include: { + _count: { select: { workflows: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + }, + orderBy: { name: 'asc' }, + }); + + res.json({ data: categories }); + } catch (err) { + next(err); + } +}); + +// GET /api/categories/:id +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const category = await prisma.category.findUnique({ + where: { id }, + include: { + _count: { select: { workflows: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + }, + }); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + // Tenant isolation: customer users can only see global or their tenant's categories + if ( + (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') && + category.tenantId !== null && + category.tenantId !== user.tenantId + ) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot access this category' }); + return; + } + + res.json({ data: category }); + } catch (err) { + next(err); + } +}); + +// POST /api/categories +router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const parsed = createCategorySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const data = parsed.data; + + // Customer admins can only create tenant-scoped categories in their own tenant + if (user.role === 'CUSTOMER_ADMIN') { + data.tenantId = user.tenantId; + } + + // Only SYSTEM_ADMIN can create global categories (tenantId = null) + if (data.tenantId === null && user.role !== 'SYSTEM_ADMIN') { + res.status(403).json({ error: 'Forbidden', message: 'Only system admins can create global categories' }); + return; + } + + const category = await prisma.category.create({ + data: { + name: data.name, + icon: data.icon ?? null, + tenantId: data.tenantId ?? null, + }, + include: { + tenant: { select: { id: true, name: true, slug: true } }, + }, + }); + + res.status(201).json({ data: category }); + } catch (err) { + next(err); + } +}); + +// PUT /api/categories/:id +router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const parsed = updateCategorySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const category = await prisma.category.findUnique({ where: { id } }); + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + // Tenant isolation + if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only edit your tenant categories' }); + return; + } + + // Only SYSTEM_ADMIN can edit global categories + if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') { + res.status(403).json({ error: 'Forbidden', message: 'Only system admins can edit global categories' }); + return; + } + + const updated = await prisma.category.update({ + where: { id }, + data: parsed.data, + include: { + tenant: { select: { id: true, name: true, slug: true } }, + }, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/categories/:id +router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const category = await prisma.category.findUnique({ + where: { id }, + include: { _count: { select: { workflows: true } } }, + }); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only delete your tenant categories' }); + return; + } + + if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') { + res.status(403).json({ error: 'Forbidden', message: 'Only system admins can delete global categories' }); + return; + } + + if (category._count.workflows > 0) { + res.status(409).json({ + error: 'Conflict', + message: `Cannot delete category with ${category._count.workflows} workflow(s). Reassign or delete them first.`, + }); + return; + } + + await prisma.category.delete({ where: { id } }); + + res.json({ message: 'Category deleted successfully' }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/routes/tenant.routes.ts b/backend/src/routes/tenant.routes.ts new file mode 100644 index 0000000..86beb6b --- /dev/null +++ b/backend/src/routes/tenant.routes.ts @@ -0,0 +1,168 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import prisma from '../lib/prisma'; +import { authenticate, authorize } from '../middleware/auth'; + +const router = Router(); + +const createTenantSchema = z.object({ + name: z.string().min(1, 'Name is required').max(255), + slug: z.string().min(1, 'Slug is required').max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'), + logo: z.string().url().optional().nullable(), + active: z.boolean().optional(), +}); + +const updateTenantSchema = z.object({ + name: z.string().min(1).max(255).optional(), + slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(), + logo: z.string().url().optional().nullable(), + active: z.boolean().optional(), +}); + +// All tenant routes require authentication +router.use(authenticate); + +// GET /api/tenants +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + + let where: any = {}; + + if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') { + // Customer users can only see their own tenant + where = { id: user.tenantId }; + } + + const tenants = await prisma.tenant.findMany({ + where, + orderBy: { name: 'asc' }, + include: { + _count: { + select: { users: true, workflows: true }, + }, + }, + }); + + res.json({ data: tenants }); + } catch (err) { + next(err); + } +}); + +// GET /api/tenants/:id +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + // Customer users can only view their own tenant + if ((user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') && id !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only access your own tenant' }); + return; + } + + const tenant = await prisma.tenant.findUnique({ + where: { id }, + include: { + _count: { + select: { users: true, workflows: true }, + }, + }, + }); + + if (!tenant) { + res.status(404).json({ error: 'Tenant not found' }); + return; + } + + res.json({ data: tenant }); + } catch (err) { + next(err); + } +}); + +// POST /api/tenants (SYSTEM_ADMIN only) +router.post('/', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const parsed = createTenantSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } }); + if (existing) { + res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' }); + return; + } + + const tenant = await prisma.tenant.create({ + data: parsed.data, + }); + + res.status(201).json({ data: tenant }); + } catch (err) { + next(err); + } +}); + +// PUT /api/tenants/:id (SYSTEM_ADMIN only) +router.put('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + const parsed = updateTenantSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const tenant = await prisma.tenant.findUnique({ where: { id } }); + if (!tenant) { + res.status(404).json({ error: 'Tenant not found' }); + return; + } + + if (parsed.data.slug && parsed.data.slug !== tenant.slug) { + const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } }); + if (existing) { + res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' }); + return; + } + } + + const updated = await prisma.tenant.update({ + where: { id }, + data: parsed.data, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/tenants/:id (SYSTEM_ADMIN only) +router.delete('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + + const tenant = await prisma.tenant.findUnique({ where: { id } }); + if (!tenant) { + res.status(404).json({ error: 'Tenant not found' }); + return; + } + + // Soft delete by deactivating + await prisma.tenant.update({ + where: { id }, + data: { active: false }, + }); + + res.json({ message: 'Tenant deactivated successfully' }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts new file mode 100644 index 0000000..4f7d85c --- /dev/null +++ b/backend/src/routes/user.routes.ts @@ -0,0 +1,333 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; +import prisma from '../lib/prisma'; +import { authenticate, authorize } from '../middleware/auth'; +import { Role } from '@prisma/client'; + +const router = Router(); + +const createUserSchema = z.object({ + email: z.string().email('Invalid email format'), + password: z.string().min(6, 'Password must be at least 6 characters'), + firstName: z.string().min(1, 'First name is required').max(100), + lastName: z.string().min(1, 'Last name is required').max(100), + role: z.nativeEnum(Role), + tenantId: z.string().uuid().optional().nullable(), + active: z.boolean().optional(), +}); + +const updateUserSchema = z.object({ + email: z.string().email().optional(), + firstName: z.string().min(1).max(100).optional(), + lastName: z.string().min(1).max(100).optional(), + role: z.nativeEnum(Role).optional(), + tenantId: z.string().uuid().optional().nullable(), + active: z.boolean().optional(), +}); + +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string().min(6, 'New password must be at least 6 characters'), +}); + +router.use(authenticate); + +// GET /api/users +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + let where: any = {}; + + if (user.role === 'CUSTOMER_ADMIN') { + // Customer admins see users in their tenant only + where = { tenantId: user.tenantId }; + } else if (user.role === 'CUSTOMER_USER') { + // Customer users can only see themselves + where = { id: user.id }; + } + // SYSTEM_ADMIN and TECHNICIAN see all users + + // Optional tenant filter via query param + if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) { + where.tenantId = req.query.tenantId as string; + } + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + tenantId: true, + active: true, + createdAt: true, + updatedAt: true, + tenant: { + select: { id: true, name: true, slug: true }, + }, + }, + orderBy: { lastName: 'asc' }, + }); + + res.json({ data: users }); + } catch (err) { + next(err); + } +}); + +// GET /api/users/:id +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const currentUser = req.user!; + const { id } = req.params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + tenantId: true, + active: true, + createdAt: true, + updatedAt: true, + tenant: { + select: { id: true, name: true, slug: true }, + }, + }, + }); + + if (!targetUser) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // Access control + if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only view users in your tenant' }); + return; + } + if (currentUser.role === 'CUSTOMER_USER' && targetUser.id !== currentUser.id) { + res.status(403).json({ error: 'Forbidden', message: 'You can only view your own profile' }); + return; + } + + res.json({ data: targetUser }); + } catch (err) { + next(err); + } +}); + +// POST /api/users +router.post('/', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const currentUser = req.user!; + const parsed = createUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const data = parsed.data; + + // CUSTOMER_ADMIN can only create users in their own tenant with limited roles + if (currentUser.role === 'CUSTOMER_ADMIN') { + if (data.role === 'SYSTEM_ADMIN' || data.role === 'TECHNICIAN') { + res.status(403).json({ error: 'Forbidden', message: 'You cannot create users with this role' }); + return; + } + data.tenantId = currentUser.tenantId; + } + + // Validate that tenant-scoped roles have a tenantId + if ((data.role === 'CUSTOMER_ADMIN' || data.role === 'CUSTOMER_USER') && !data.tenantId) { + res.status(400).json({ error: 'Validation error', message: 'Customer roles require a tenantId' }); + return; + } + + // Check email uniqueness + const existing = await prisma.user.findUnique({ where: { email: data.email } }); + if (existing) { + res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' }); + return; + } + + const passwordHash = await bcrypt.hash(data.password, 12); + + const user = await prisma.user.create({ + data: { + email: data.email, + passwordHash, + firstName: data.firstName, + lastName: data.lastName, + role: data.role, + tenantId: data.tenantId ?? null, + active: data.active ?? true, + }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + tenantId: true, + active: true, + createdAt: true, + }, + }); + + res.status(201).json({ data: user }); + } catch (err) { + next(err); + } +}); + +// PUT /api/users/:id +router.put('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const currentUser = req.user!; + const { id } = req.params; + const parsed = updateUserSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const targetUser = await prisma.user.findUnique({ where: { id } }); + if (!targetUser) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // CUSTOMER_ADMIN can only edit users in their tenant + if (currentUser.role === 'CUSTOMER_ADMIN') { + if (targetUser.tenantId !== currentUser.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only edit users in your tenant' }); + return; + } + // Cannot change role to system-level roles + if (parsed.data.role && (parsed.data.role === 'SYSTEM_ADMIN' || parsed.data.role === 'TECHNICIAN')) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot assign this role' }); + return; + } + // Cannot change tenantId + delete parsed.data.tenantId; + } + + // Check email uniqueness if changing + if (parsed.data.email && parsed.data.email !== targetUser.email) { + const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } }); + if (existing) { + res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' }); + return; + } + } + + const updated = await prisma.user.update({ + where: { id }, + data: parsed.data, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + tenantId: true, + active: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/users/:id +router.delete('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const currentUser = req.user!; + const { id } = req.params; + + if (id === currentUser.id) { + res.status(400).json({ error: 'Bad request', message: 'You cannot delete your own account' }); + return; + } + + const targetUser = await prisma.user.findUnique({ where: { id } }); + if (!targetUser) { + res.status(404).json({ error: 'User not found' }); + return; + } + + if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only delete users in your tenant' }); + return; + } + + // Soft delete + await prisma.user.update({ + where: { id }, + data: { active: false }, + }); + + res.json({ message: 'User deactivated successfully' }); + } catch (err) { + next(err); + } +}); + +// PATCH /api/users/:id/password +router.patch('/:id/password', async (req: Request, res: Response, next: NextFunction) => { + try { + const currentUser = req.user!; + const { id } = req.params; + + // Users can only change their own password (admins can reset via update) + if (id !== currentUser.id && currentUser.role !== 'SYSTEM_ADMIN') { + res.status(403).json({ error: 'Forbidden', message: 'You can only change your own password' }); + return; + } + + const parsed = changePasswordSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const user = await prisma.user.findUnique({ where: { id } }); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + // Verify current password (skip for SYSTEM_ADMIN resetting another user's password) + if (id === currentUser.id) { + const validPassword = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!validPassword) { + res.status(400).json({ error: 'Invalid current password' }); + return; + } + } + + const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12); + + await prisma.user.update({ + where: { id }, + data: { passwordHash }, + }); + + res.json({ message: 'Password changed successfully' }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/routes/workflow.routes.ts b/backend/src/routes/workflow.routes.ts new file mode 100644 index 0000000..7d5db71 --- /dev/null +++ b/backend/src/routes/workflow.routes.ts @@ -0,0 +1,630 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import prisma from '../lib/prisma'; +import { authenticate, authorize } from '../middleware/auth'; +import { WorkflowStatus, StepType } from '@prisma/client'; + +const router = Router(); + +const createWorkflowSchema = z.object({ + title: z.string().min(1, 'Title is required').max(500), + description: z.string().optional().nullable(), + tenantId: z.string().uuid('Invalid tenant ID'), + categoryId: z.string().uuid().optional().nullable(), + isTemplate: z.boolean().optional(), + tags: z.array(z.string()).optional(), + steps: z.array(z.object({ + order: z.number().int().min(0), + title: z.string().min(1), + content: z.string().min(1), + type: z.nativeEnum(StepType).optional(), + isRequired: z.boolean().optional(), + })).optional(), +}); + +const updateWorkflowSchema = z.object({ + title: z.string().min(1).max(500).optional(), + description: z.string().optional().nullable(), + categoryId: z.string().uuid().optional().nullable(), + isTemplate: z.boolean().optional(), + tags: z.array(z.string()).optional(), +}); + +const statusChangeSchema = z.object({ + status: z.nativeEnum(WorkflowStatus), +}); + +const createStepSchema = z.object({ + order: z.number().int().min(0), + title: z.string().min(1, 'Step title is required'), + content: z.string().min(1, 'Step content is required'), + type: z.nativeEnum(StepType).optional(), + isRequired: z.boolean().optional(), +}); + +const updateStepSchema = z.object({ + order: z.number().int().min(0).optional(), + title: z.string().min(1).optional(), + content: z.string().min(1).optional(), + type: z.nativeEnum(StepType).optional(), + isRequired: z.boolean().optional(), +}); + +const reorderStepsSchema = z.object({ + steps: z.array(z.object({ + id: z.string().uuid(), + order: z.number().int().min(0), + })), +}); + +router.use(authenticate); + +function getTenantFilter(user: Express.Request['user']): { tenantId?: string } { + if (!user) return {}; + if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') { + return {}; + } + return { tenantId: user.tenantId! }; +} + +// GET /api/workflows +router.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const tenantFilter = getTenantFilter(user); + + const { + search, + status, + categoryId, + tags, + tenantId, + isTemplate, + page = '1', + limit = '20', + } = req.query; + + const where: any = { ...tenantFilter }; + + // Allow SYSTEM_ADMIN/TECHNICIAN to filter by specific tenant + if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && tenantId) { + where.tenantId = tenantId as string; + } + + if (search) { + where.OR = [ + { title: { contains: search as string, mode: 'insensitive' } }, + { description: { contains: search as string, mode: 'insensitive' } }, + ]; + } + + if (status) { + where.status = status as WorkflowStatus; + } + + if (categoryId) { + where.categoryId = categoryId as string; + } + + if (tags) { + const tagArray = (tags as string).split(','); + where.tags = { hasSome: tagArray }; + } + + if (isTemplate !== undefined) { + where.isTemplate = isTemplate === 'true'; + } + + const pageNum = Math.max(1, parseInt(page as string, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 20)); + const skip = (pageNum - 1) * limitNum; + + const [workflows, total] = await Promise.all([ + prisma.workflow.findMany({ + where, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + _count: { select: { steps: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limitNum, + }), + prisma.workflow.count({ where }), + ]); + + res.json({ + data: workflows, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); + } catch (err) { + next(err); + } +}); + +// GET /api/workflows/:id +router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const workflow = await prisma.workflow.findUnique({ + where: { id }, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + steps: { orderBy: { order: 'asc' } }, + }, + }); + + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + // Tenant isolation check + if (user.role !== 'SYSTEM_ADMIN' && user.role !== 'TECHNICIAN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot access this workflow' }); + return; + } + + res.json({ data: workflow }); + } catch (err) { + next(err); + } +}); + +// POST /api/workflows +router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const parsed = createWorkflowSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const data = parsed.data; + + // Customer admins can only create in their own tenant + if (user.role === 'CUSTOMER_ADMIN') { + if (data.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only create workflows in your tenant' }); + return; + } + } + + // Verify tenant exists + const tenant = await prisma.tenant.findUnique({ where: { id: data.tenantId } }); + if (!tenant) { + res.status(404).json({ error: 'Tenant not found' }); + return; + } + + // Verify category if provided + if (data.categoryId) { + const category = await prisma.category.findUnique({ where: { id: data.categoryId } }); + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + } + + const workflow = await prisma.workflow.create({ + data: { + title: data.title, + description: data.description ?? null, + tenantId: data.tenantId, + categoryId: data.categoryId ?? null, + isTemplate: data.isTemplate ?? false, + tags: data.tags ?? [], + createdById: user.id, + steps: data.steps ? { + create: data.steps.map((step) => ({ + order: step.order, + title: step.title, + content: step.content, + type: step.type ?? 'INSTRUCTION', + isRequired: step.isRequired ?? true, + })), + } : undefined, + }, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + steps: { orderBy: { order: 'asc' } }, + }, + }); + + res.status(201).json({ data: workflow }); + } catch (err) { + next(err); + } +}); + +// PUT /api/workflows/:id +router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const parsed = updateWorkflowSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + // Tenant isolation + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot edit this workflow' }); + return; + } + + // Verify category if changing + if (parsed.data.categoryId) { + const category = await prisma.category.findUnique({ where: { id: parsed.data.categoryId } }); + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + } + + const updated = await prisma.workflow.update({ + where: { id }, + data: { + ...parsed.data, + updatedById: user.id, + version: { increment: 1 }, + }, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + steps: { orderBy: { order: 'asc' } }, + }, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// PATCH /api/workflows/:id/status +router.patch('/:id/status', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const parsed = statusChangeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot modify this workflow' }); + return; + } + + const updated = await prisma.workflow.update({ + where: { id }, + data: { + status: parsed.data.status, + updatedById: user.id, + }, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + }, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/workflows/:id +router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot delete this workflow' }); + return; + } + + await prisma.workflow.delete({ where: { id } }); + + res.json({ message: 'Workflow deleted successfully' }); + } catch (err) { + next(err); + } +}); + +// POST /api/workflows/:id/duplicate +router.post('/:id/duplicate', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const targetTenantId = req.body.tenantId as string | undefined; + + const workflow = await prisma.workflow.findUnique({ + where: { id }, + include: { steps: { orderBy: { order: 'asc' } } }, + }); + + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + // Tenant isolation + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You cannot duplicate this workflow' }); + return; + } + + // Determine target tenant + const duplicateTenantId = targetTenantId || (user.role === 'CUSTOMER_ADMIN' ? user.tenantId! : workflow.tenantId); + + // Customer admins can only duplicate into their own tenant + if (user.role === 'CUSTOMER_ADMIN' && duplicateTenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden', message: 'You can only duplicate into your own tenant' }); + return; + } + + const duplicated = await prisma.workflow.create({ + data: { + title: `${workflow.title} (Copy)`, + description: workflow.description, + tenantId: duplicateTenantId, + categoryId: workflow.categoryId, + status: 'DRAFT', + isTemplate: false, + tags: workflow.tags, + createdById: user.id, + steps: { + create: workflow.steps.map((step) => ({ + order: step.order, + title: step.title, + content: step.content, + type: step.type, + isRequired: step.isRequired, + })), + }, + }, + include: { + category: { select: { id: true, name: true, icon: true } }, + createdBy: { select: { id: true, firstName: true, lastName: true, email: true } }, + tenant: { select: { id: true, name: true, slug: true } }, + steps: { orderBy: { order: 'asc' } }, + }, + }); + + res.status(201).json({ data: duplicated }); + } catch (err) { + next(err); + } +}); + +// --- Workflow Steps --- + +// POST /api/workflows/:id/steps +router.post('/:id/steps', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const parsed = createStepSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + + const step = await prisma.workflowStep.create({ + data: { + workflowId: id, + order: parsed.data.order, + title: parsed.data.title, + content: parsed.data.content, + type: parsed.data.type ?? 'INSTRUCTION', + isRequired: parsed.data.isRequired ?? true, + }, + }); + + // Update workflow's updatedBy + await prisma.workflow.update({ + where: { id }, + data: { updatedById: user.id }, + }); + + res.status(201).json({ data: step }); + } catch (err) { + next(err); + } +}); + +// PUT /api/workflows/:id/steps/:stepId +router.put('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id, stepId } = req.params; + + const parsed = updateStepSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + + const step = await prisma.workflowStep.findFirst({ + where: { id: stepId, workflowId: id }, + }); + + if (!step) { + res.status(404).json({ error: 'Step not found' }); + return; + } + + const updated = await prisma.workflowStep.update({ + where: { id: stepId }, + data: parsed.data, + }); + + await prisma.workflow.update({ + where: { id }, + data: { updatedById: user.id }, + }); + + res.json({ data: updated }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/workflows/:id/steps/:stepId +router.delete('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id, stepId } = req.params; + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + + const step = await prisma.workflowStep.findFirst({ + where: { id: stepId, workflowId: id }, + }); + + if (!step) { + res.status(404).json({ error: 'Step not found' }); + return; + } + + await prisma.workflowStep.delete({ where: { id: stepId } }); + + await prisma.workflow.update({ + where: { id }, + data: { updatedById: user.id }, + }); + + res.json({ message: 'Step deleted successfully' }); + } catch (err) { + next(err); + } +}); + +// PATCH /api/workflows/:id/steps/reorder +router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => { + try { + const user = req.user!; + const { id } = req.params; + + const parsed = reorderStepsSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors }); + return; + } + + const workflow = await prisma.workflow.findUnique({ where: { id } }); + if (!workflow) { + res.status(404).json({ error: 'Workflow not found' }); + return; + } + + if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + + // Update all step orders in a transaction + await prisma.$transaction( + parsed.data.steps.map((step) => + prisma.workflowStep.update({ + where: { id: step.id }, + data: { order: step.order }, + }) + ) + ); + + await prisma.workflow.update({ + where: { id }, + data: { updatedById: user.id }, + }); + + const updatedSteps = await prisma.workflowStep.findMany({ + where: { workflowId: id }, + orderBy: { order: 'asc' }, + }); + + res.json({ data: updatedSteps }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..83e68f9 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,18 @@ +import { Role } from '@prisma/client'; + +declare global { + namespace Express { + interface Request { + user?: { + id: string; + email: string; + role: Role; + tenantId: string | null; + firstName: string; + lastName: string; + }; + } + } +} + +export {}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..a5c9a51 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..032699a --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,16 @@ +module.exports = { + apps: [ + { + name: 'workflow-backend', + cwd: './backend', + script: 'dist/index.js', + env: { + NODE_ENV: 'production', + PORT: 4000 + }, + instances: 1, + autorestart: true, + max_memory_restart: '500M' + } + ] +}; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..757a2c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Workflow Portal + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..000fff1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "workflow-portal-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@headlessui/react": "^2.2.0", + "@hello-pangea/dnd": "^17.0.0", + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..7663c71 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ +W \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0d48f51 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,99 @@ +import { Route, Routes } from 'react-router-dom'; +import { ProtectedRoute } from './hooks/useAuth'; +import Layout from './components/Layout'; +import LoginPage from './pages/LoginPage'; +import Dashboard from './pages/Dashboard'; +import WorkflowList from './pages/WorkflowList'; +import WorkflowView from './pages/WorkflowView'; +import WorkflowEditor from './pages/WorkflowEditor'; +import TenantList from './pages/TenantList'; +import UserList from './pages/UserList'; + +function AppLayout({ children }: { children: React.ReactNode }) { + return {children}; +} + +export default function App() { + return ( + + } /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + ); +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..444dc7c --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,61 @@ +import { AlertTriangle } from 'lucide-react'; +import Modal from './Modal'; + +interface ConfirmDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'danger' | 'warning'; + loading?: boolean; +} + +export default function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + message, + confirmLabel = 'Bestätigen', + cancelLabel = 'Abbrechen', + variant = 'danger', + loading = false, +}: ConfirmDialogProps) { + return ( + +
+
+ +
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..8382ee8 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,28 @@ +import { Inbox } from 'lucide-react'; + +interface EmptyStateProps { + icon?: React.ElementType; + title: string; + description?: string; + action?: React.ReactNode; +} + +export default function EmptyState({ + icon: Icon = Inbox, + title, + description, + action, +}: EmptyStateProps) { + return ( +
+
+ +
+

{title}

+ {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..f5c0a5b --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,212 @@ +import { Fragment, useState } from 'react'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { + Building2, + FileText, + LayoutDashboard, + LogOut, + Menu, + Settings, + User, + Users, + X, +} from 'lucide-react'; +import { Menu as HeadlessMenu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { useAuth } from '../hooks/useAuth'; + +const navigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Workflows', href: '/workflows', icon: FileText }, + { name: 'Vorlagen', href: '/workflows?template=true', icon: Settings }, +]; + +const adminNavigation = [ + { name: 'Mandanten', href: '/tenants', icon: Building2 }, + { name: 'Benutzer', href: '/users', icon: Users }, +]; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const isAdmin = + user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN'; + + const handleLogout = async () => { + await logout(); + navigate('/login'); + }; + + const navLinkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${ + isActive + ? 'bg-primary-50 text-primary-700' + : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900' + }`; + + const sidebarContent = ( + <> +
+
+ WP +
+
+

+ Workflow Portal +

+ {user?.tenant && ( +

{user.tenant.name}

+ )} +
+
+ + + +
+
+
+ {user?.firstName?.[0]} + {user?.lastName?.[0]} +
+
+

+ {user?.firstName} {user?.lastName} +

+

{user?.email}

+
+
+
+ + ); + + return ( +
+ {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Mobile sidebar */} + + + {/* Desktop sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+ + +
+ + + + + + {user?.firstName} {user?.lastName} + + + + + + {({ focus }) => ( + + + Profil + + )} + + + {({ focus }) => ( + + )} + + + + +
+ + {/* Page content */} +
{children}
+
+
+ ); +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..948bceb --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -0,0 +1,77 @@ +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment } from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const sizeClasses = { + sm: 'max-w-md', + md: 'max-w-lg', + lg: 'max-w-2xl', + xl: 'max-w-4xl', +}; + +export default function Modal({ + open, + onClose, + title, + children, + size = 'md', +}: ModalProps) { + return ( + + + +
+ + +
+
+ + +
+ {title && ( + + {title} + + )} + +
+
{children}
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..bfe58ef --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,62 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export default function Pagination({ + page, + totalPages, + onPageChange, +}: PaginationProps) { + if (totalPages <= 1) return null; + + const pages: (number | '...')[] = []; + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) { + pages.push(i); + } else if (pages[pages.length - 1] !== '...') { + pages.push('...'); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..fc8a042 --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,58 @@ +import { Search, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + debounceMs?: number; +} + +export default function SearchBar({ + value, + onChange, + placeholder = 'Suchen...', + debounceMs = 300, +}: SearchBarProps) { + const [internal, setInternal] = useState(value); + const timerRef = useRef>(); + + useEffect(() => { + setInternal(value); + }, [value]); + + const handleChange = useCallback( + (val: string) => { + setInternal(val); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onChange(val), debounceMs); + }, + [onChange, debounceMs] + ); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + return ( +
+ + handleChange(e.target.value)} + placeholder={placeholder} + className="input-field pl-10 pr-9" + /> + {internal && ( + + )} +
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..77a90e4 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,38 @@ +import type { Workflow } from '../types'; + +const statusConfig: Record< + Workflow['status'], + { label: string; className: string } +> = { + DRAFT: { + label: 'Entwurf', + className: 'bg-gray-100 text-gray-700 ring-gray-300', + }, + REVIEW: { + label: 'In Prüfung', + className: 'bg-yellow-50 text-yellow-700 ring-yellow-300', + }, + PUBLISHED: { + label: 'Veröffentlicht', + className: 'bg-green-50 text-green-700 ring-green-300', + }, + ARCHIVED: { + label: 'Archiviert', + className: 'bg-red-50 text-red-700 ring-red-300', + }, +}; + +interface StatusBadgeProps { + status: Workflow['status']; +} + +export default function StatusBadge({ status }: StatusBadgeProps) { + const config = statusConfig[status]; + return ( + + {config.label} + + ); +} diff --git a/frontend/src/components/StepTypeIcon.tsx b/frontend/src/components/StepTypeIcon.tsx new file mode 100644 index 0000000..daed44e --- /dev/null +++ b/frontend/src/components/StepTypeIcon.tsx @@ -0,0 +1,40 @@ +import { + AlertTriangle, + CheckSquare, + FileText, + Info, + Terminal, +} from 'lucide-react'; +import type { WorkflowStep } from '../types'; + +const typeConfig: Record< + WorkflowStep['type'], + { icon: React.ElementType; label: string; color: string } +> = { + INSTRUCTION: { icon: FileText, label: 'Anleitung', color: 'text-blue-600' }, + CHECKLIST: { icon: CheckSquare, label: 'Checkliste', color: 'text-green-600' }, + WARNING: { icon: AlertTriangle, label: 'Warnung', color: 'text-amber-600' }, + INFO: { icon: Info, label: 'Info', color: 'text-cyan-600' }, + COMMAND: { icon: Terminal, label: 'Befehl', color: 'text-purple-600' }, +}; + +interface StepTypeIconProps { + type: WorkflowStep['type']; + showLabel?: boolean; + size?: number; +} + +export default function StepTypeIcon({ + type, + showLabel = false, + size = 18, +}: StepTypeIconProps) { + const config = typeConfig[type]; + const Icon = config.icon; + return ( + + + {showLabel && {config.label}} + + ); +} diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..8dfd74a --- /dev/null +++ b/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,111 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import type { User } from '../types'; +import { authApi } from '../services/api'; + +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const isAuthenticated = !!user; + + const loadUser = useCallback(async () => { + const token = localStorage.getItem('accessToken'); + if (!token) { + setLoading(false); + return; + } + try { + const userData = await authApi.me(); + setUser(userData); + } catch { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setUser(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadUser(); + }, [loadUser]); + + const login = useCallback(async (email: string, password: string) => { + const response = await authApi.login(email, password); + localStorage.setItem('accessToken', response.accessToken); + localStorage.setItem('refreshToken', response.refreshToken); + setUser(response.user); + }, []); + + const logout = useCallback(async () => { + try { + await authApi.logout(); + } catch { + // ignore + } + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setUser(null); + }, []); + + const value = useMemo( + () => ({ user, isAuthenticated, loading, login, logout }), + [user, isAuthenticated, loading, login, logout] + ); + + return {children}; +} + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +interface ProtectedRouteProps { + children: React.ReactNode; + requiredRoles?: User['role'][]; +} + +export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) { + const { isAuthenticated, loading, user } = useAuth(); + const location = useLocation(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (requiredRoles && user && !requiredRoles.includes(user.role)) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9f0e20a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,30 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + @apply scroll-smooth; + } + body { + @apply min-h-screen bg-gray-50 text-gray-900; + } +} + +@layer components { + .btn-primary { + @apply inline-flex items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + .btn-secondary { + @apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + .btn-danger { + @apply inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + .input-field { + @apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500; + } + .card { + @apply rounded-xl border border-gray-200 bg-white shadow-sm; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f5ef6c2 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { Toaster } from 'react-hot-toast'; +import { AuthProvider } from './hooks/useAuth'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..8e9f99d --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Archive, + Clock, + FileCheck, + FilePlus, + FileText, + Layers, + Plus, +} from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useAuth } from '../hooks/useAuth'; +import { workflowsApi, tenantsApi } from '../services/api'; +import type { Tenant, Workflow } from '../types'; +import StatusBadge from '../components/StatusBadge'; + +interface Stats { + total: number; + published: number; + review: number; + draft: number; +} + +export default function Dashboard() { + const { user } = useAuth(); + const [stats, setStats] = useState({ total: 0, published: 0, review: 0, draft: 0 }); + const [recent, setRecent] = useState([]); + const [tenants, setTenants] = useState([]); + const [selectedTenant, setSelectedTenant] = useState(''); + const [loading, setLoading] = useState(true); + + const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN'; + + useEffect(() => { + loadData(); + if (isAdmin) { + tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {}); + } + }, [isAdmin]); + + const loadData = async () => { + setLoading(true); + try { + const params: Record = { page: 1, pageSize: 5 }; + if (selectedTenant) params.tenantId = selectedTenant; + + const [allRes, pubRes, revRes, draftRes] = await Promise.all([ + workflowsApi.list({ ...params, pageSize: 1 } as Parameters[0]), + workflowsApi.list({ ...params, pageSize: 1, status: 'PUBLISHED' } as Parameters[0]), + workflowsApi.list({ ...params, pageSize: 1, status: 'REVIEW' } as Parameters[0]), + workflowsApi.list({ ...params, pageSize: 1, status: 'DRAFT' } as Parameters[0]), + ]); + + setStats({ + total: allRes.total, + published: pubRes.total, + review: revRes.total, + draft: draftRes.total, + }); + + const recentRes = await workflowsApi.list(params as Parameters[0]); + setRecent(recentRes.data); + } catch { + toast.error('Daten konnten nicht geladen werden'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [selectedTenant]); + + const statCards = [ + { + label: 'Gesamt', + value: stats.total, + icon: FileText, + color: 'bg-primary-50 text-primary-600', + }, + { + label: 'Veröffentlicht', + value: stats.published, + icon: FileCheck, + color: 'bg-green-50 text-green-600', + }, + { + label: 'In Prüfung', + value: stats.review, + icon: Clock, + color: 'bg-yellow-50 text-yellow-600', + }, + { + label: 'Entwurf', + value: stats.draft, + icon: Archive, + color: 'bg-gray-100 text-gray-600', + }, + ]; + + return ( +
+ {/* Header */} +
+
+

+ Willkommen, {user?.firstName}! +

+

+ Hier ist eine Übersicht Ihrer Workflows. +

+
+ {isAdmin && tenants.length > 0 && ( + + )} +
+ + {/* Stats */} +
+ {statCards.map((card) => ( +
+
+
+ +
+
+
+

+ {loading ? '-' : card.value} +

+

{card.label}

+
+
+ ))} +
+ + {/* Quick actions */} +
+ +
+ +
+
+

Neuer Workflow

+

+ Erstellen Sie einen neuen Workflow +

+
+ + +
+ +
+
+

Vorlagen durchsuchen

+

+ Starten Sie mit einer bestehenden Vorlage +

+
+ +
+ + {/* Recent workflows */} +
+
+

+ Aktuelle Workflows +

+ + Alle anzeigen + +
+ {loading ? ( +
+
+
+ ) : recent.length === 0 ? ( +
+ Noch keine Workflows vorhanden. +
+ ) : ( +
    + {recent.map((wf) => ( +
  • + +
    + +
    +
    +

    + {wf.title} +

    +

    + {wf.steps?.length || 0} Schritte · v{wf.version} +

    +
    + + + {new Date(wf.updatedAt).toLocaleDateString('de-DE')} + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..c5c2b2e --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Eye, EyeOff, LogIn } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useAuth } from '../hooks/useAuth'; + +export default function LoginPage() { + const { login } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await login(email, password); + toast.success('Erfolgreich angemeldet'); + navigate(from, { replace: true }); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || 'Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.'; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Left branding panel */} +
+
+
+
+ WP +
+

Workflow Portal

+
+

+ Verwalten Sie Ihre Workflows, Dokumentationen und Anleitungen an + einem zentralen Ort. Effizient, strukturiert und teamübergreifend. +

+
+ {[ + 'Workflows erstellen und veröffentlichen', + 'Schritt-für-Schritt Anleitungen', + 'Multi-Mandanten Unterstützung', + 'Vorlagen für wiederkehrende Prozesse', + ].map((feature) => ( +
+
+ {feature} +
+ ))} +
+
+
+ + {/* Right login form */} +
+
+
+
+ WP +
+

+ Workflow Portal +

+
+ +

Anmelden

+

+ Melden Sie sich mit Ihren Zugangsdaten an. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + placeholder="name@firma.de" + className="input-field" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + placeholder="Passwort eingeben" + className="input-field pr-10" + /> + +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/TenantList.tsx b/frontend/src/pages/TenantList.tsx new file mode 100644 index 0000000..455910d --- /dev/null +++ b/frontend/src/pages/TenantList.tsx @@ -0,0 +1,340 @@ +import { useEffect, useState } from 'react'; +import { + Building2, + Check, + Edit, + Plus, + Power, + X, +} from 'lucide-react'; +import toast from 'react-hot-toast'; +import { tenantsApi } from '../services/api'; +import type { Tenant } from '../types'; +import Modal from '../components/Modal'; +import ConfirmDialog from '../components/ConfirmDialog'; +import SearchBar from '../components/SearchBar'; +import Pagination from '../components/Pagination'; +import EmptyState from '../components/EmptyState'; + +export default function TenantList() { + const [tenants, setTenants] = useState([]); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingTenant, setEditingTenant] = useState(null); + const [formName, setFormName] = useState(''); + const [formSlug, setFormSlug] = useState(''); + const [formActive, setFormActive] = useState(true); + const [formSaving, setFormSaving] = useState(false); + + // Deactivate dialog + const [deactivateTarget, setDeactivateTarget] = useState(null); + const [deactivating, setDeactivating] = useState(false); + + useEffect(() => { + loadTenants(); + }, [search, page]); + + const loadTenants = async () => { + setLoading(true); + try { + const res = await tenantsApi.list({ + page, + pageSize: 20, + search: search || undefined, + }); + setTenants(res.data); + setTotalPages(res.totalPages); + } catch { + toast.error('Mandanten konnten nicht geladen werden'); + } finally { + setLoading(false); + } + }; + + const openCreate = () => { + setEditingTenant(null); + setFormName(''); + setFormSlug(''); + setFormActive(true); + setModalOpen(true); + }; + + const openEdit = (tenant: Tenant) => { + setEditingTenant(tenant); + setFormName(tenant.name); + setFormSlug(tenant.slug); + setFormActive(tenant.active); + setModalOpen(true); + }; + + const handleSave = async () => { + if (!formName.trim() || !formSlug.trim()) { + toast.error('Name und Slug sind erforderlich'); + return; + } + setFormSaving(true); + try { + if (editingTenant) { + await tenantsApi.update(editingTenant.id, { + name: formName, + slug: formSlug, + active: formActive, + }); + toast.success('Mandant aktualisiert'); + } else { + await tenantsApi.create({ + name: formName, + slug: formSlug, + active: formActive, + }); + toast.success('Mandant erstellt'); + } + setModalOpen(false); + loadTenants(); + } catch { + toast.error('Speichern fehlgeschlagen'); + } finally { + setFormSaving(false); + } + }; + + const handleDeactivate = async () => { + if (!deactivateTarget) return; + setDeactivating(true); + try { + await tenantsApi.update(deactivateTarget.id, { + active: !deactivateTarget.active, + }); + toast.success( + deactivateTarget.active + ? 'Mandant deaktiviert' + : 'Mandant aktiviert' + ); + setDeactivateTarget(null); + loadTenants(); + } catch { + toast.error('Aktion fehlgeschlagen'); + } finally { + setDeactivating(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

Mandanten

+

+ Verwalten Sie die Mandanten des Portals. +

+
+ +
+ + {/* Search */} +
+ { + setSearch(val); + setPage(1); + }} + placeholder="Mandanten suchen..." + /> +
+ + {/* Table */} + {loading ? ( +
+
+
+ ) : tenants.length === 0 ? ( + + + Neuer Mandant + + } + /> + ) : ( +
+
+ + + + + + + + + + + {tenants.map((tenant) => ( + + + + + + + ))} + +
+ Name + + Slug + + Status + + Aktionen +
+
+
+ {tenant.name[0]} +
+ + {tenant.name} + +
+
+ {tenant.slug} + + {tenant.active ? ( + + + Aktiv + + ) : ( + + + Inaktiv + + )} + +
+ + +
+
+
+
+ )} + + {totalPages > 1 && ( + + )} + + {/* Create/Edit Modal */} + setModalOpen(false)} + title={editingTenant ? 'Mandant bearbeiten' : 'Neuer Mandant'} + > +
+
+ + { + setFormName(e.target.value); + if (!editingTenant) { + setFormSlug( + e.target.value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + ); + } + }} + placeholder="Firmenname GmbH" + className="input-field" + /> +
+
+ + setFormSlug(e.target.value)} + placeholder="firmenname-gmbh" + className="input-field" + /> +
+ +
+ + +
+
+
+ + {/* Deactivate Confirm */} + setDeactivateTarget(null)} + onConfirm={handleDeactivate} + title={ + deactivateTarget?.active + ? 'Mandant deaktivieren?' + : 'Mandant aktivieren?' + } + message={ + deactivateTarget?.active + ? `Der Mandant "${deactivateTarget?.name}" wird deaktiviert. Benutzer des Mandanten können sich nicht mehr anmelden.` + : `Der Mandant "${deactivateTarget?.name}" wird wieder aktiviert.` + } + confirmLabel={deactivateTarget?.active ? 'Deaktivieren' : 'Aktivieren'} + variant={deactivateTarget?.active ? 'danger' : 'warning'} + loading={deactivating} + /> +
+ ); +} diff --git a/frontend/src/pages/UserList.tsx b/frontend/src/pages/UserList.tsx new file mode 100644 index 0000000..806e164 --- /dev/null +++ b/frontend/src/pages/UserList.tsx @@ -0,0 +1,437 @@ +import { useEffect, useState } from 'react'; +import { + Check, + Edit, + Plus, + Power, + Users, + X, +} from 'lucide-react'; +import toast from 'react-hot-toast'; +import { usersApi, tenantsApi } from '../services/api'; +import type { Tenant, User } from '../types'; +import Modal from '../components/Modal'; +import ConfirmDialog from '../components/ConfirmDialog'; +import SearchBar from '../components/SearchBar'; +import Pagination from '../components/Pagination'; +import EmptyState from '../components/EmptyState'; + +const roleLabels: Record = { + SYSTEM_ADMIN: 'System-Admin', + TECHNICIAN: 'Techniker', + CUSTOMER_ADMIN: 'Kunden-Admin', + CUSTOMER_USER: 'Kunden-Benutzer', +}; + +const roleOptions: { value: User['role']; label: string }[] = [ + { value: 'SYSTEM_ADMIN', label: 'System-Admin' }, + { value: 'TECHNICIAN', label: 'Techniker' }, + { value: 'CUSTOMER_ADMIN', label: 'Kunden-Admin' }, + { value: 'CUSTOMER_USER', label: 'Kunden-Benutzer' }, +]; + +export default function UserList() { + const [users, setUsers] = useState([]); + const [tenants, setTenants] = useState([]); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + const [tenantFilter, setTenantFilter] = useState(''); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(true); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [formFirstName, setFormFirstName] = useState(''); + const [formLastName, setFormLastName] = useState(''); + const [formEmail, setFormEmail] = useState(''); + const [formPassword, setFormPassword] = useState(''); + const [formRole, setFormRole] = useState('CUSTOMER_USER'); + const [formTenantId, setFormTenantId] = useState(''); + const [formSaving, setFormSaving] = useState(false); + + // Deactivate dialog + const [deactivateTarget, setDeactivateTarget] = useState(null); + const [deactivating, setDeactivating] = useState(false); + + useEffect(() => { + tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {}); + }, []); + + useEffect(() => { + loadUsers(); + }, [search, roleFilter, tenantFilter, page]); + + const loadUsers = async () => { + setLoading(true); + try { + const res = await usersApi.list({ + page, + pageSize: 20, + search: search || undefined, + role: roleFilter || undefined, + tenantId: tenantFilter || undefined, + }); + setUsers(res.data); + setTotalPages(res.totalPages); + } catch { + toast.error('Benutzer konnten nicht geladen werden'); + } finally { + setLoading(false); + } + }; + + const openCreate = () => { + setEditingUser(null); + setFormFirstName(''); + setFormLastName(''); + setFormEmail(''); + setFormPassword(''); + setFormRole('CUSTOMER_USER'); + setFormTenantId(''); + setModalOpen(true); + }; + + const openEdit = (user: User) => { + setEditingUser(user); + setFormFirstName(user.firstName); + setFormLastName(user.lastName); + setFormEmail(user.email); + setFormPassword(''); + setFormRole(user.role); + setFormTenantId(user.tenantId || ''); + setModalOpen(true); + }; + + const handleSave = async () => { + if (!formFirstName.trim() || !formLastName.trim() || !formEmail.trim()) { + toast.error('Vorname, Nachname und E-Mail sind erforderlich'); + return; + } + if (!editingUser && !formPassword) { + toast.error('Passwort ist erforderlich'); + return; + } + setFormSaving(true); + try { + const payload: Partial & { password?: string } = { + firstName: formFirstName, + lastName: formLastName, + email: formEmail, + role: formRole, + tenantId: formTenantId || undefined, + }; + if (formPassword) { + payload.password = formPassword; + } + if (editingUser) { + await usersApi.update(editingUser.id, payload); + toast.success('Benutzer aktualisiert'); + } else { + await usersApi.create(payload); + toast.success('Benutzer erstellt'); + } + setModalOpen(false); + loadUsers(); + } catch { + toast.error('Speichern fehlgeschlagen'); + } finally { + setFormSaving(false); + } + }; + + const handleDeactivate = async () => { + if (!deactivateTarget) return; + setDeactivating(true); + try { + await usersApi.delete(deactivateTarget.id); + toast.success('Benutzer deaktiviert'); + setDeactivateTarget(null); + loadUsers(); + } catch { + toast.error('Aktion fehlgeschlagen'); + } finally { + setDeactivating(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

Benutzer

+

+ Verwalten Sie die Benutzer und deren Rollen. +

+
+ +
+ + {/* Filters */} +
+
+ { + setSearch(val); + setPage(1); + }} + placeholder="Benutzer suchen..." + /> +
+ + +
+ + {/* Table */} + {loading ? ( +
+
+
+ ) : users.length === 0 ? ( + + + Neuer Benutzer + + } + /> + ) : ( +
+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
+ Name + + E-Mail + + Rolle + + Mandant + + Aktionen +
+
+
+ {u.firstName[0]} + {u.lastName[0]} +
+ + {u.firstName} {u.lastName} + +
+
+ {u.email} + + + {roleLabels[u.role]} + + + {u.tenant?.name || '-'} + +
+ + +
+
+
+
+ )} + + {totalPages > 1 && ( + + )} + + {/* Create/Edit Modal */} + setModalOpen(false)} + title={editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'} + size="lg" + > +
+
+
+ + setFormFirstName(e.target.value)} + placeholder="Max" + className="input-field" + /> +
+
+ + setFormLastName(e.target.value)} + placeholder="Mustermann" + className="input-field" + /> +
+
+
+ + setFormEmail(e.target.value)} + placeholder="max@firma.de" + className="input-field" + /> +
+
+ + setFormPassword(e.target.value)} + placeholder={editingUser ? 'Neues Passwort' : 'Passwort'} + className="input-field" + /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + {/* Deactivate Confirm */} + setDeactivateTarget(null)} + onConfirm={handleDeactivate} + title="Benutzer deaktivieren?" + message={`Der Benutzer "${deactivateTarget?.firstName} ${deactivateTarget?.lastName}" wird deaktiviert und kann sich nicht mehr anmelden.`} + confirmLabel="Deaktivieren" + loading={deactivating} + /> +
+ ); +} diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx new file mode 100644 index 0000000..d8c700e --- /dev/null +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -0,0 +1,489 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + ArrowLeft, + GripVertical, + Plus, + Save, + Send, + Trash2, + X, +} from 'lucide-react'; +import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import toast from 'react-hot-toast'; +import { workflowsApi, categoriesApi } from '../services/api'; +import type { Category, Workflow, WorkflowStep } from '../types'; +import StepTypeIcon from '../components/StepTypeIcon'; + +interface StepDraft { + _key: string; + id?: string; + title: string; + content: string; + type: WorkflowStep['type']; + isRequired: boolean; +} + +const stepTypes: { value: WorkflowStep['type']; label: string }[] = [ + { value: 'INSTRUCTION', label: 'Anleitung' }, + { value: 'CHECKLIST', label: 'Checkliste' }, + { value: 'WARNING', label: 'Warnung' }, + { value: 'INFO', label: 'Info' }, + { value: 'COMMAND', label: 'Befehl' }, +]; + +let keyCounter = 0; +function nextKey() { + return `step_${++keyCounter}_${Date.now()}`; +} + +export default function WorkflowEditor() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const isNew = !id; + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + const [isTemplate, setIsTemplate] = useState(false); + const [steps, setSteps] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(!isNew); + const [saving, setSaving] = useState(false); + const [autoSaved, setAutoSaved] = useState(false); + + const autoSaveTimer = useRef>(); + + useEffect(() => { + categoriesApi.list().then(setCategories).catch(() => {}); + }, []); + + useEffect(() => { + if (!id) return; + setLoading(true); + workflowsApi + .get(id) + .then((wf) => { + setTitle(wf.title); + setDescription(wf.description || ''); + setCategoryId(wf.categoryId || ''); + setTagsInput(wf.tags.join(', ')); + setIsTemplate(wf.isTemplate); + const sorted = [...(wf.steps || [])].sort((a, b) => a.order - b.order); + setSteps( + sorted.map((s) => ({ + _key: nextKey(), + id: s.id, + title: s.title, + content: s.content, + type: s.type, + isRequired: s.isRequired, + })) + ); + }) + .catch(() => { + toast.error('Workflow konnte nicht geladen werden'); + navigate('/workflows'); + }) + .finally(() => setLoading(false)); + }, [id, navigate]); + + const parseTags = (): string[] => + tagsInput + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + const buildPayload = (status?: Workflow['status']) => ({ + title, + description: description || undefined, + categoryId: categoryId || undefined, + tags: parseTags(), + isTemplate, + status, + steps: steps.map((s, idx) => ({ + id: s.id, + title: s.title, + content: s.content, + type: s.type, + isRequired: s.isRequired, + order: idx + 1, + })), + }); + + const handleSave = useCallback( + async (status?: Workflow['status']) => { + if (!title.trim()) { + toast.error('Bitte geben Sie einen Titel ein'); + return; + } + setSaving(true); + try { + const payload = buildPayload(status); + if (isNew) { + const created = await workflowsApi.create({ + ...payload, + status: status || 'DRAFT', + } as Partial); + toast.success('Workflow erstellt'); + navigate(`/workflows/${created.id}/edit`, { replace: true }); + } else { + await workflowsApi.update(id!, payload as Partial); + toast.success('Workflow gespeichert'); + } + } catch { + toast.error('Speichern fehlgeschlagen'); + } finally { + setSaving(false); + } + }, + [title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate] + ); + + // Auto-save (only for existing workflows) + useEffect(() => { + if (isNew || loading) return; + clearTimeout(autoSaveTimer.current); + autoSaveTimer.current = setTimeout(async () => { + if (!title.trim()) return; + try { + await workflowsApi.update(id!, buildPayload() as Partial); + setAutoSaved(true); + setTimeout(() => setAutoSaved(false), 2000); + } catch { + // silent + } + }, 5000); + return () => clearTimeout(autoSaveTimer.current); + }, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]); + + const addStep = () => { + setSteps((prev) => [ + ...prev, + { + _key: nextKey(), + title: '', + content: '', + type: 'INSTRUCTION', + isRequired: false, + }, + ]); + }; + + const removeStep = (index: number) => { + setSteps((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateStep = (index: number, field: keyof StepDraft, value: unknown) => { + setSteps((prev) => + prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)) + ); + }; + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const from = result.source.index; + const to = result.destination.index; + if (from === to) return; + setSteps((prev) => { + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Back */} +
+ + {autoSaved && ( + Automatisch gespeichert + )} +
+ +

+ {isNew ? 'Neuer Workflow' : 'Workflow bearbeiten'} +

+ + {/* Metadata */} +
+
+ + setTitle(e.target.value)} + placeholder="z.B. Server-Neustart Anleitung" + className="input-field" + /> +
+ +
+ +