apply security audit
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jose": "^6.0.11",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import authentikService from '../services/authentik.service';
|
import authentikService from '../services/authentik.service';
|
||||||
import tokenService from '../services/token.service';
|
import tokenService from '../services/token.service';
|
||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { AuthRequest } from '../types/auth.types';
|
|
||||||
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||||
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||||
|
import { getUserRole } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract given_name and family_name from Authentik userinfo.
|
* Extract given_name and family_name from Authentik userinfo.
|
||||||
@@ -60,10 +61,13 @@ class AuthController {
|
|||||||
const userAgent = extractUserAgent(req);
|
const userAgent = extractUserAgent(req);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { code } = req.body as AuthRequest;
|
const callbackSchema = z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
redirect_uri: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// Validate code
|
const parseResult = callbackSchema.safeParse(req.body);
|
||||||
if (!code) {
|
if (!parseResult.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Authorization code is required',
|
message: 'Authorization code is required',
|
||||||
@@ -71,6 +75,8 @@ class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { code } = parseResult.data;
|
||||||
|
|
||||||
logger.info('Processing OAuth callback', { hasCode: !!code });
|
logger.info('Processing OAuth callback', { hasCode: !!code });
|
||||||
|
|
||||||
// Step 1: Exchange code for tokens
|
// Step 1: Exchange code for tokens
|
||||||
@@ -83,9 +89,9 @@ class AuthController {
|
|||||||
// Step 3: Verify ID token if present
|
// Step 3: Verify ID token if present
|
||||||
if (tokens.id_token) {
|
if (tokens.id_token) {
|
||||||
try {
|
try {
|
||||||
authentikService.verifyIdToken(tokens.id_token);
|
await authentikService.verifyIdToken(tokens.id_token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('ID token verification failed', { error });
|
logger.warn('ID token verification failed — continuing with userinfo', { error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,11 +196,13 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Generate internal JWT token
|
// Step 5: Generate internal JWT token
|
||||||
|
const role = await getUserRole(user.id);
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
groups,
|
groups,
|
||||||
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
@@ -309,9 +317,12 @@ class AuthController {
|
|||||||
*/
|
*/
|
||||||
async handleRefresh(req: Request, res: Response): Promise<void> {
|
async handleRefresh(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { refreshToken } = req.body;
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
if (!refreshToken) {
|
const parseResult = refreshSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Refresh token is required',
|
message: 'Refresh token is required',
|
||||||
@@ -319,6 +330,8 @@ class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { refreshToken } = parseResult.data;
|
||||||
|
|
||||||
// Verify refresh token
|
// Verify refresh token
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
@@ -358,10 +371,12 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
|
const role = await getUserRole(user.id);
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Token refreshed successfully', {
|
logger.info('Token refreshed successfully', {
|
||||||
|
|||||||
@@ -3,36 +3,6 @@ import tokenService from '../services/token.service';
|
|||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { JwtPayload } from '../types/auth.types';
|
import { JwtPayload } from '../types/auth.types';
|
||||||
import { auditPermissionDenied } from './audit.middleware';
|
|
||||||
import { AuditResourceType } from '../services/audit.service';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Application roles — extend as needed when Authentik group mapping is added
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type AppRole = 'admin' | 'member' | 'viewer';
|
|
||||||
|
|
||||||
export const Permission = {
|
|
||||||
ADMIN_ACCESS: 'admin:access',
|
|
||||||
MEMBER_WRITE: 'member:write',
|
|
||||||
MEMBER_READ: 'member:read',
|
|
||||||
INCIDENT_WRITE:'incident:write',
|
|
||||||
INCIDENT_READ: 'incident:read',
|
|
||||||
EXPORT: 'export',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type Permission = typeof Permission[keyof typeof Permission];
|
|
||||||
|
|
||||||
// Simple permission → required role mapping.
|
|
||||||
// Adjust once Authentik group sync is implemented.
|
|
||||||
const PERMISSION_ROLES: Record<Permission, AppRole[]> = {
|
|
||||||
'admin:access': ['admin'],
|
|
||||||
'member:write': ['admin', 'member'],
|
|
||||||
'member:read': ['admin', 'member', 'viewer'],
|
|
||||||
'incident:write': ['admin', 'member'],
|
|
||||||
'incident:read': ['admin', 'member', 'viewer'],
|
|
||||||
'export': ['admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extend Express Request type to include user
|
// Extend Express Request type to include user
|
||||||
declare global {
|
declare global {
|
||||||
@@ -42,7 +12,7 @@ declare global {
|
|||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
email: string;
|
email: string;
|
||||||
authentikSub: string;
|
authentikSub: string;
|
||||||
role?: AppRole; // populated when role is stored in DB / JWT
|
role?: string; // populated when role is stored in DB / JWT
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,6 +92,7 @@ export const authenticate = async (
|
|||||||
email: decoded.email,
|
email: decoded.email,
|
||||||
authentikSub: decoded.authentikSub,
|
authentikSub: decoded.authentikSub,
|
||||||
groups: decoded.groups ?? [],
|
groups: decoded.groups ?? [],
|
||||||
|
role: decoded.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug('User authenticated successfully', {
|
logger.debug('User authenticated successfully', {
|
||||||
@@ -139,60 +110,6 @@ export const authenticate = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Role-based access control middleware
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* requirePermission — factory that returns Express middleware enforcing a
|
|
||||||
* specific permission. Must be placed after `authenticate` in the chain.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* router.get('/admin/audit-log', authenticate, requirePermission('admin:access'), handler);
|
|
||||||
*
|
|
||||||
* When access is denied, a PERMISSION_DENIED audit entry is written before
|
|
||||||
* the 403 response is sent.
|
|
||||||
*
|
|
||||||
* NOTE: Until Authentik group → role mapping is persisted to the users table
|
|
||||||
* or JWT, this middleware checks req.user.role. Temporary workaround:
|
|
||||||
* hard-code specific admin user IDs via the ADMIN_USER_IDS env variable, OR
|
|
||||||
* add a `role` column to the users table (recommended).
|
|
||||||
*/
|
|
||||||
export const requirePermission = (permission: Permission) => {
|
|
||||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
||||||
if (!req.user) {
|
|
||||||
res.status(401).json({ success: false, message: 'Not authenticated' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userRole: AppRole = req.user.role ?? 'viewer';
|
|
||||||
const allowedRoles = PERMISSION_ROLES[permission];
|
|
||||||
|
|
||||||
if (!allowedRoles.includes(userRole)) {
|
|
||||||
logger.warn('Permission denied', {
|
|
||||||
userId: req.user.id,
|
|
||||||
permission,
|
|
||||||
userRole,
|
|
||||||
path: req.path,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Audit the denied access — fire-and-forget
|
|
||||||
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
|
|
||||||
required_permission: permission,
|
|
||||||
user_role: userRole,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Insufficient permissions',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional authentication middleware
|
* Optional authentication middleware
|
||||||
* Attaches user if token is valid, but doesn't require it
|
* Attaches user if token is valid, but doesn't require it
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
|
|||||||
'admin:access': 'admin',
|
'admin:access': 'admin',
|
||||||
'audit:read': 'admin',
|
'audit:read': 'admin',
|
||||||
'audit:export': 'admin',
|
'audit:export': 'admin',
|
||||||
|
'members:read': 'mitglied',
|
||||||
|
'members:write': 'kommandant',
|
||||||
|
'vehicles:write': 'kommandant',
|
||||||
|
'vehicles:status': 'gruppenfuehrer',
|
||||||
|
'vehicles:delete': 'admin',
|
||||||
|
'equipment:write': 'gruppenfuehrer',
|
||||||
|
'equipment:delete': 'admin',
|
||||||
|
'events:write': 'gruppenfuehrer',
|
||||||
|
'events:categories': 'gruppenfuehrer',
|
||||||
|
'atemschutz:write': 'gruppenfuehrer',
|
||||||
|
'atemschutz:delete': 'kommandant',
|
||||||
|
'bookings:write': 'gruppenfuehrer',
|
||||||
|
'bookings:delete': 'admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasPermission(role: AppRole, permission: string): boolean {
|
function hasPermission(role: AppRole, permission: string): boolean {
|
||||||
@@ -103,7 +116,9 @@ export function requirePermission(permission: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await getUserRole(req.user.id);
|
const role = (req.user as any).role
|
||||||
|
? (req.user as any).role as AppRole
|
||||||
|
: await getUserRole(req.user.id);
|
||||||
|
|
||||||
// Attach role to request for downstream use (e.g., bericht_text redaction)
|
// Attach role to request for downstream use (e.g., bericht_text redaction)
|
||||||
(req as Request & { userRole?: AppRole }).userRole = role;
|
(req as Request & { userRole?: AppRole }).userRole = role;
|
||||||
@@ -149,7 +164,9 @@ export function requireGroups(requiredGroups: string[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userGroups: string[] = (req.user as any).groups ?? [];
|
logger.warn('DEPRECATED: requireGroups() — migrate to requirePermission()', { requiredGroups });
|
||||||
|
|
||||||
|
const userGroups: string[] = req.user?.groups ?? [];
|
||||||
const hasAccess = requiredGroups.some(g => userGroups.includes(g));
|
const hasAccess = requiredGroups.some(g => userGroups.includes(g));
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import atemschutzController from '../controllers/atemschutz.controller';
|
import atemschutzController from '../controllers/atemschutz.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -15,13 +12,13 @@ router.get('/stats', authenticate, atemschutzController.getStats.bind(atems
|
|||||||
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
|
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
|
||||||
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
|
||||||
|
|
||||||
// ── Write — admin + kommandant ───────────────────────────────────────────────
|
// ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController));
|
router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
|
||||||
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController));
|
router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
|
||||||
|
|
||||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
// ── Delete — kommandant+ ────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController));
|
router.delete('/:id', authenticate, requirePermission('atemschutz:delete'), atemschutzController.delete.bind(atemschutzController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import authController from '../controllers/auth.controller';
|
import authController from '../controllers/auth.controller';
|
||||||
import { optionalAuth } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ router.post('/callback', authController.handleCallback);
|
|||||||
/**
|
/**
|
||||||
* @route POST /api/auth/logout
|
* @route POST /api/auth/logout
|
||||||
* @desc Logout user
|
* @desc Logout user
|
||||||
* @access Public (optional auth for logging purposes)
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.post('/logout', optionalAuth, authController.handleLogout);
|
router.post('/logout', authenticate, authController.handleLogout);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/auth/refresh
|
* @route POST /api/auth/refresh
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bookingController from '../controllers/booking.controller';
|
import bookingController from '../controllers/booking.controller';
|
||||||
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -22,13 +19,13 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
|
|||||||
// ── Write operations ──────────────────────────────────────────────────────────
|
// ── Write operations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, bookingController.create.bind(bookingController));
|
router.post('/', authenticate, bookingController.create.bind(bookingController));
|
||||||
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController));
|
router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
|
||||||
|
|
||||||
// Soft-cancel (sets abgesagt=TRUE)
|
// Soft-cancel (sets abgesagt=TRUE)
|
||||||
router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController));
|
router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController));
|
||||||
|
|
||||||
// Hard-delete (admin only)
|
// Hard-delete (admin only)
|
||||||
router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController));
|
router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));
|
||||||
|
|
||||||
// ── Single booking read — after specific routes to avoid path conflicts ───────
|
// ── Single booking read — after specific routes to avoid path conflicts ───────
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import equipmentController from '../controllers/equipment.controller';
|
import equipmentController from '../controllers/equipment.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,15 +15,15 @@ router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleW
|
|||||||
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
|
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
|
||||||
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
|
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
|
||||||
|
|
||||||
// ── Write — admin + fahrmeister ──────────────────────────────────────────────
|
// ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController));
|
router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController));
|
||||||
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController));
|
router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController));
|
||||||
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController));
|
router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController));
|
||||||
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController));
|
router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController));
|
||||||
|
|
||||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController));
|
router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import eventsController from '../controllers/events.controller';
|
import eventsController from '../controllers/events.controller';
|
||||||
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
import { authenticate, optionalAuth } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** Groups that may create, update, or cancel events */
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Categories
|
// Categories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -20,34 +17,34 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/events/kategorien
|
* POST /api/events/kategorien
|
||||||
* Create a new category. Requires admin or moderator.
|
* Create a new category. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/kategorien',
|
'/kategorien',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:categories'),
|
||||||
eventsController.createKategorie.bind(eventsController)
|
eventsController.createKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/events/kategorien/:id
|
* PATCH /api/events/kategorien/:id
|
||||||
* Update an existing category. Requires admin or moderator.
|
* Update an existing category. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/kategorien/:id',
|
'/kategorien/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:categories'),
|
||||||
eventsController.updateKategorie.bind(eventsController)
|
eventsController.updateKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/events/kategorien/:id
|
* DELETE /api/events/kategorien/:id
|
||||||
* Delete a category (only if no events reference it). Requires admin or moderator.
|
* Delete a category (only if no events reference it). Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/kategorien/:id',
|
'/kategorien/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:categories'),
|
||||||
eventsController.deleteKategorie.bind(eventsController)
|
eventsController.deleteKategorie.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,23 +103,23 @@ router.get(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/events/import
|
* POST /api/events/import
|
||||||
* Bulk import events from CSV data. Requires admin or moderator.
|
* Bulk import events from CSV data. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/import',
|
'/import',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:write'),
|
||||||
eventsController.importEvents.bind(eventsController)
|
eventsController.importEvents.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/events
|
* POST /api/events
|
||||||
* Create a new event. Requires admin or moderator.
|
* Create a new event. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:write'),
|
||||||
eventsController.createEvent.bind(eventsController)
|
eventsController.createEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -134,34 +131,34 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/events/:id
|
* PATCH /api/events/:id
|
||||||
* Update an existing event. Requires admin or moderator.
|
* Update an existing event. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:write'),
|
||||||
eventsController.updateEvent.bind(eventsController)
|
eventsController.updateEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/events/:id
|
* DELETE /api/events/:id
|
||||||
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator.
|
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:write'),
|
||||||
eventsController.cancelEvent.bind(eventsController)
|
eventsController.cancelEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/events/:id/delete
|
* POST /api/events/:id/delete
|
||||||
* Hard-delete an event permanently. Requires admin or moderator.
|
* Hard-delete an event permanently. Requires gruppenfuehrer+.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/delete',
|
'/:id/delete',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireGroups(WRITE_GROUPS),
|
requirePermission('events:write'),
|
||||||
eventsController.deleteEvent.bind(eventsController)
|
eventsController.deleteEvent.bind(eventsController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +1,16 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import memberController from '../controllers/member.controller';
|
import memberController from '../controllers/member.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import logger from '../utils/logger';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Role/permission middleware
|
// Apply authentication to every route in this router.
|
||||||
//
|
// requirePermission is applied per-route because PATCH allows the
|
||||||
// The JWT currently carries: { userId, email, authentikSub }.
|
// owner to update their own limited fields even without 'members:write'.
|
||||||
// Roles come from Authentik group membership stored in the
|
|
||||||
// `groups` array on the UserInfo response. The auth controller
|
|
||||||
// already upserts the user in the DB on every login; this
|
|
||||||
// middleware resolves the role from req.user (extended below).
|
|
||||||
//
|
|
||||||
// Until a full roles column exists in the users table, roles are
|
|
||||||
// derived from a well-known Authentik group naming convention:
|
|
||||||
//
|
|
||||||
// "feuerwehr-admin" → AppRole 'admin'
|
|
||||||
// "feuerwehr-kommandant" → AppRole 'kommandant'
|
|
||||||
// (everything else) → AppRole 'mitglied'
|
|
||||||
//
|
|
||||||
// The groups are passed through the JWT as req.user.groups (added
|
|
||||||
// by the extended type below). Replace this logic with a DB
|
|
||||||
// lookup once a roles column is added to users.
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
router.use(authenticate);
|
||||||
type AppRole = 'admin' | 'kommandant' | 'mitglied';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the AppRole from Authentik groups attached to the JWT.
|
|
||||||
* Mutates req.user.role so downstream controllers can read it directly.
|
|
||||||
*/
|
|
||||||
const resolveRole = (req: Request, _res: Response, next: NextFunction): void => {
|
|
||||||
if (req.user) {
|
|
||||||
const groups: string[] = (req.user as any).groups ?? [];
|
|
||||||
if (groups.includes('feuerwehr-admin')) {
|
|
||||||
req.user.role = 'admin';
|
|
||||||
} else if (groups.includes('feuerwehr-kommandant')) {
|
|
||||||
req.user.role = 'kommandant' as any;
|
|
||||||
} else {
|
|
||||||
req.user.role = 'mitglied' as any;
|
|
||||||
}
|
|
||||||
logger.debug('resolveRole', { userId: req.user.id, role: req.user.role });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory: creates a middleware that enforces the minimum required role.
|
|
||||||
* Role hierarchy: admin > kommandant > mitglied
|
|
||||||
*/
|
|
||||||
const requirePermission = (permission: 'members:read' | 'members:write') => {
|
|
||||||
return (req: Request, res: Response, next: NextFunction): void => {
|
|
||||||
const role = (req.user as any)?.role ?? 'mitglied';
|
|
||||||
|
|
||||||
const writeRoles: AppRole[] = ['admin', 'kommandant'];
|
|
||||||
const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied'];
|
|
||||||
|
|
||||||
const allowed =
|
|
||||||
permission === 'members:write'
|
|
||||||
? writeRoles.includes(role)
|
|
||||||
: readRoles.includes(role);
|
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: 'Keine Berechtigung für diese Aktion.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Apply authentication + role resolution to every route in this
|
|
||||||
// router. Note: requirePermission is applied per-route because
|
|
||||||
// PATCH allows the owner to update their own limited fields even
|
|
||||||
// without 'members:write'.
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
router.use(authenticate, resolveRole);
|
|
||||||
|
|
||||||
// IMPORTANT: The static /stats route must be registered BEFORE
|
// IMPORTANT: The static /stats route must be registered BEFORE
|
||||||
// the dynamic /:userId route, otherwise Express would match
|
// the dynamic /:userId route, otherwise Express would match
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import vehicleController from '../controllers/vehicle.controller';
|
import vehicleController from '../controllers/vehicle.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requireGroups } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['dashboard_admin'];
|
|
||||||
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -16,15 +13,15 @@ router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleCont
|
|||||||
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
|
||||||
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
|
||||||
|
|
||||||
// ── Write — admin only ────────────────────────────────────────────────────────
|
// ── Write — kommandant+ ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController));
|
router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController));
|
||||||
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController));
|
router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController));
|
||||||
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController));
|
router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController));
|
||||||
|
|
||||||
// ── Status + maintenance log — admin + fahrmeister ────────────────────────────
|
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
||||||
|
|
||||||
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController));
|
router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||||
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController));
|
router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import * as jose from 'jose';
|
||||||
import authentikConfig from '../config/authentik';
|
import authentikConfig from '../config/authentik';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { TokenResponse, UserInfo } from '../types/auth.types';
|
import { TokenResponse, UserInfo } from '../types/auth.types';
|
||||||
|
|
||||||
class AuthentikService {
|
class AuthentikService {
|
||||||
|
private jwks: ReturnType<typeof jose.createRemoteJWKSet> | null = null;
|
||||||
|
|
||||||
|
private getJwks(): ReturnType<typeof jose.createRemoteJWKSet> {
|
||||||
|
if (!this.jwks) {
|
||||||
|
// Derive JWKS URI from the issuer URL
|
||||||
|
// Issuer: https://auth.example.com/application/o/myapp/
|
||||||
|
// JWKS: https://auth.example.com/application/o/myapp/jwks/
|
||||||
|
const jwksUri = new URL('jwks/', authentikConfig.issuer.endsWith('/') ? authentikConfig.issuer : authentikConfig.issuer + '/');
|
||||||
|
this.jwks = jose.createRemoteJWKSet(jwksUri);
|
||||||
|
}
|
||||||
|
return this.jwks;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange authorization code for access and ID tokens
|
* Exchange authorization code for access and ID tokens
|
||||||
*/
|
*/
|
||||||
@@ -61,48 +75,26 @@ class AuthentikService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify and decode ID token (basic validation)
|
* Verify and decode ID token using JWKS
|
||||||
* Note: For production, use a proper JWT verification library like jose or jsonwebtoken
|
|
||||||
*/
|
*/
|
||||||
verifyIdToken(idToken: string): any {
|
async verifyIdToken(idToken: string): Promise<jose.JWTPayload> {
|
||||||
try {
|
try {
|
||||||
// Split the token into parts
|
const { payload } = await jose.jwtVerify(idToken, this.getJwks(), {
|
||||||
const parts = idToken.split('.');
|
issuer: authentikConfig.issuer,
|
||||||
if (parts.length !== 3) {
|
});
|
||||||
throw new Error('Invalid ID token format');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the payload (Base64URL)
|
|
||||||
const payload = JSON.parse(
|
|
||||||
Buffer.from(parts[1], 'base64url').toString('utf-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new Error('Invalid ID token payload');
|
throw new Error('Invalid ID token payload: missing sub or email');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration
|
logger.info('ID token verified successfully via JWKS', {
|
||||||
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
||||||
throw new Error('ID token has expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check issuer
|
|
||||||
if (payload.iss && !payload.iss.includes(authentikConfig.issuer)) {
|
|
||||||
logger.warn('ID token issuer mismatch', {
|
|
||||||
expected: authentikConfig.issuer,
|
|
||||||
received: payload.iss,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('ID token verified successfully', {
|
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to verify ID token', { error });
|
logger.error('Failed to verify ID token via JWKS', { error });
|
||||||
throw new Error('Invalid ID token');
|
throw new Error('Invalid ID token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class TokenService {
|
|||||||
email: payload.email,
|
email: payload.email,
|
||||||
authentikSub: payload.authentikSub,
|
authentikSub: payload.authentikSub,
|
||||||
groups: payload.groups ?? [],
|
groups: payload.groups ?? [],
|
||||||
|
role: payload.role,
|
||||||
},
|
},
|
||||||
environment.jwt.secret,
|
environment.jwt.secret,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface JwtPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
authentikSub: string;
|
authentikSub: string;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
|
role?: string;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { AuthContextType, AuthState, User } from '../types/auth.types';
|
import { AuthContextType, AuthState, User } from '../types/auth.types';
|
||||||
import { authService } from '../services/auth';
|
import { authService } from '../services/auth';
|
||||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage';
|
import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
|
||||||
import { useNotification } from './NotificationContext';
|
import { useNotification } from './NotificationContext';
|
||||||
import { setAuthInitialized } from '../services/api';
|
import { setAuthInitialized } from '../services/api';
|
||||||
|
|
||||||
@@ -71,10 +71,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
setState((prev) => ({ ...prev, isLoading: true }));
|
setState((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
const { token, user } = await authService.handleCallback(code);
|
const { token, refreshToken, user } = await authService.handleCallback(code);
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
setToken(token);
|
setToken(token);
|
||||||
|
setRefreshToken(refreshToken);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -115,6 +116,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
// Clear local state
|
// Clear local state
|
||||||
removeToken();
|
removeToken();
|
||||||
|
removeRefreshToken();
|
||||||
removeUser();
|
removeUser();
|
||||||
setState({
|
setState({
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -10,35 +9,16 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Divider,
|
Divider,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Settings as SettingsIcon, Notifications, Palette, Language, Save, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
|
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
|
||||||
import { useThemeMode } from '../contexts/ThemeContext';
|
import { useThemeMode } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
const notification = useNotification();
|
|
||||||
const { themeMode, setThemeMode } = useThemeMode();
|
const { themeMode, setThemeMode } = useThemeMode();
|
||||||
|
|
||||||
// Settings state
|
|
||||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
|
||||||
const [alarmNotifications, setAlarmNotifications] = useState(true);
|
|
||||||
const [maintenanceReminders, setMaintenanceReminders] = useState(false);
|
|
||||||
const [systemNotifications, setSystemNotifications] = useState(true);
|
|
||||||
const [compactView, setCompactView] = useState(true);
|
|
||||||
const [animations, setAnimations] = useState(true);
|
|
||||||
|
|
||||||
const handleSaveSettings = () => {
|
|
||||||
try {
|
|
||||||
// In a real application, save settings to backend
|
|
||||||
notification.showSuccess('Einstellungen erfolgreich gespeichert');
|
|
||||||
} catch (error) {
|
|
||||||
notification.showError('Fehler beim Speichern der Einstellungen');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
@@ -58,41 +38,24 @@ function Settings() {
|
|||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={emailNotifications}
|
|
||||||
onChange={(e) => setEmailNotifications(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="E-Mail-Benachrichtigungen"
|
label="E-Mail-Benachrichtigungen"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={alarmNotifications}
|
|
||||||
onChange={(e) => setAlarmNotifications(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Einsatz-Alarme"
|
label="Einsatz-Alarme"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={maintenanceReminders}
|
|
||||||
onChange={(e) => setMaintenanceReminders(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Wartungserinnerungen"
|
label="Wartungserinnerungen"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={systemNotifications}
|
|
||||||
onChange={(e) => setSystemNotifications(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="System-Benachrichtigungen"
|
label="System-Benachrichtigungen"
|
||||||
/>
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
(Bald verfügbar)
|
||||||
|
</Typography>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -109,23 +72,16 @@ function Settings() {
|
|||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={compactView}
|
|
||||||
onChange={(e) => setCompactView(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Kompakte Ansicht"
|
label="Kompakte Ansicht"
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={<Switch checked={false} disabled />}
|
||||||
<Switch
|
|
||||||
checked={animations}
|
|
||||||
onChange={(e) => setAnimations(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Animationen"
|
label="Animationen"
|
||||||
/>
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
(Bald verfügbar)
|
||||||
|
</Typography>
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
Farbschema
|
Farbschema
|
||||||
@@ -204,18 +160,6 @@ function Settings() {
|
|||||||
wird in zukünftigen Updates implementiert.
|
wird in zukünftigen Updates implementiert.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
size="large"
|
|
||||||
startIcon={<Save />}
|
|
||||||
onClick={handleSaveSettings}
|
|
||||||
>
|
|
||||||
Einstellungen speichern
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||||
import { API_URL } from '../utils/config';
|
import { API_URL } from '../utils/config';
|
||||||
import { getToken, removeToken, removeUser } from '../utils/storage';
|
import { getToken, setToken, removeToken, removeUser, getRefreshToken, removeRefreshToken } from '../utils/storage';
|
||||||
|
|
||||||
let authInitialized = false;
|
let authInitialized = false;
|
||||||
let isRedirectingToLogin = false;
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
function processQueue(error: any, token: string | null = null) {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
export function setAuthInitialized(value: boolean): void {
|
export function setAuthInitialized(value: boolean): void {
|
||||||
authInitialized = value;
|
authInitialized = value;
|
||||||
if (value === true) {
|
if (value === true) {
|
||||||
@@ -54,15 +71,71 @@ class ApiService {
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
if (authInitialized && !isRedirectingToLogin) {
|
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||||
isRedirectingToLogin = true;
|
|
||||||
// Clear tokens and redirect to login
|
// Don't attempt refresh during auth initialization or if already retried
|
||||||
console.warn('Unauthorized request, redirecting to login');
|
if (!authInitialized || originalRequest._retry) {
|
||||||
removeToken();
|
if (authInitialized && !isRedirectingToLogin) {
|
||||||
removeUser();
|
isRedirectingToLogin = true;
|
||||||
window.location.href = '/login';
|
removeToken();
|
||||||
|
removeRefreshToken();
|
||||||
|
removeUser();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(this.handleError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise<AxiosResponse>((resolve, reject) => {
|
||||||
|
failedQueue.push({
|
||||||
|
resolve: (token: string) => {
|
||||||
|
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${token}` };
|
||||||
|
resolve(this.axiosInstance.request(originalRequest));
|
||||||
|
},
|
||||||
|
reject: (err: any) => {
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
isRefreshing = false;
|
||||||
|
if (!isRedirectingToLogin) {
|
||||||
|
isRedirectingToLogin = true;
|
||||||
|
removeToken();
|
||||||
|
removeRefreshToken();
|
||||||
|
removeUser();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(this.handleError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a raw axios call (not the intercepted instance) to avoid loops
|
||||||
|
const response = await axios.post(`${API_URL}/api/auth/refresh`, { refreshToken });
|
||||||
|
const newToken = response.data.data.accessToken;
|
||||||
|
setToken(newToken);
|
||||||
|
processQueue(null, newToken);
|
||||||
|
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${newToken}` };
|
||||||
|
return this.axiosInstance.request(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
if (!isRedirectingToLogin) {
|
||||||
|
isRedirectingToLogin = true;
|
||||||
|
removeToken();
|
||||||
|
removeRefreshToken();
|
||||||
|
removeUser();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(this.handleError(error));
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
}
|
}
|
||||||
// During initialization, silently reject without redirecting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry on 429 (Too Many Requests) with exponential backoff
|
// Retry on 429 (Too Many Requests) with exponential backoff
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const REDIRECT_URI = `${window.location.origin}/auth/callback`;
|
|||||||
|
|
||||||
export interface AuthCallbackResponse {
|
export interface AuthCallbackResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export const authService = {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
token: response.data.data.accessToken,
|
token: response.data.data.accessToken,
|
||||||
|
refreshToken: response.data.data.refreshToken,
|
||||||
user: mapBackendUser(response.data.data.user),
|
user: mapBackendUser(response.data.data.user),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ export const removeToken = (): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REFRESH_TOKEN_KEY = 'auth_refresh_token';
|
||||||
|
|
||||||
|
export const getRefreshToken = (): string | null => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting refresh token from localStorage:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setRefreshToken = (token: string): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting refresh token in localStorage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeRefreshToken = (): void => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing refresh token from localStorage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getUser = (): User | null => {
|
export const getUser = (): User | null => {
|
||||||
try {
|
try {
|
||||||
const userStr = localStorage.getItem(USER_KEY);
|
const userStr = localStorage.getItem(USER_KEY);
|
||||||
|
|||||||
Reference in New Issue
Block a user