create-lumina-project 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +3 -3
  2. package/template/.env.example +2 -1
  3. package/template/nodemon.json +1 -1
  4. package/template/package.json +26 -15
  5. package/template/resources/css/app.css +491 -0
  6. package/template/resources/js/Pages/Errors/404.tsx +32 -0
  7. package/template/resources/js/Pages/Maintenance.tsx +34 -0
  8. package/template/resources/js/Pages/Status.tsx +103 -0
  9. package/template/resources/js/Pages/Welcome.tsx +82 -0
  10. package/template/resources/js/app.tsx +43 -0
  11. package/template/resources/js/tsconfig.json +20 -0
  12. package/template/resources/js/vite-env.d.ts +1 -0
  13. package/template/resources/views/app.html +14 -0
  14. package/template/src/controllers/UserController.ts +5 -1
  15. package/template/src/controllers/WebController.ts +94 -0
  16. package/template/src/database/migrations/20260420050855-create_refresh_tokens.js +5 -0
  17. package/template/src/exceptions/Handler.ts +4 -4
  18. package/template/src/middlewares/ApiAuth.ts +67 -0
  19. package/template/src/middlewares/Csrf.ts +5 -4
  20. package/template/src/middlewares/InertiaMiddleware.ts +95 -0
  21. package/template/src/middlewares/Maintenance.ts +5 -5
  22. package/template/src/middlewares/RoleGuard.ts +20 -0
  23. package/template/src/middlewares/Validator.ts +17 -1
  24. package/template/src/middlewares/WebAuth.ts +122 -0
  25. package/template/src/models/BaseModel.ts +24 -0
  26. package/template/src/models/RefreshToken.ts +6 -5
  27. package/template/src/models/User.ts +3 -5
  28. package/template/src/routes/api.ts +16 -8
  29. package/template/src/routes/web.ts +10 -43
  30. package/template/src/server.ts +115 -0
  31. package/template/src/types/express/index.d.ts +8 -1
  32. package/template/src/types/express-inertia.d.ts +28 -0
  33. package/template/vite.config.ts +20 -0
  34. package/template/views/404.html +0 -233
  35. package/template/views/maintenance.html +0 -464
  36. package/template/views/status.html +0 -545
  37. package/template/views/welcome.html +0 -495
@@ -0,0 +1,103 @@
1
+ interface StatusProps {
2
+ status: string;
3
+ environment: string;
4
+ uptime: number;
5
+ memoryMB: string;
6
+ }
7
+
8
+ export default function Status({ status, environment, uptime, memoryMB }: StatusProps) {
9
+ const formatUptime = (seconds: number) => {
10
+ const d = Math.floor(seconds / (3600 * 24));
11
+ const h = Math.floor((seconds % (3600 * 24)) / 3600);
12
+ const m = Math.floor((seconds % 3600) / 60);
13
+ return `${d}d ${h}h ${m}m`;
14
+ };
15
+
16
+ return (
17
+ <>
18
+ <div className="bg-gradient"></div>
19
+ <div className="orb orb-1"></div>
20
+ <div className="orb orb-2"></div>
21
+ <div className="orb orb-3"></div>
22
+ <div className="grid-pattern"></div>
23
+
24
+ <div className="main">
25
+ <div className="header">
26
+ <a href="/" className="back-link">
27
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
28
+ Back to Home
29
+ </a>
30
+ <h1 className="title">System Status</h1>
31
+ <p className="subtitle">Real-time health overview of the Lumina application</p>
32
+ </div>
33
+
34
+ <div className="status-bar" style={{ animationDelay: '0s', marginBottom: '-0.5rem' }}>
35
+ <div className={`status ${status === 'UP' ? '' : 'status-error'}`}>
36
+ <span className={`status-dot ${status === 'UP' ? '' : 'error'}`}></span>
37
+ {status === 'UP' ? 'All Systems Operational' : 'System Degraded'}
38
+ </div>
39
+ </div>
40
+
41
+ <div className="card">
42
+ <div className="card-title">Application</div>
43
+ <div className="info-grid">
44
+ <div className="info-row">
45
+ <div className="info-label">
46
+ <div className="info-icon">🔒</div>
47
+ Status
48
+ </div>
49
+ <div className={`info-value ${status === 'UP' ? 'status-up' : 'status-down'}`}>{status}</div>
50
+ </div>
51
+ <div className="info-row">
52
+ <div className="info-label">
53
+ <div className="info-icon">🌐</div>
54
+ Environment
55
+ </div>
56
+ <div className="info-value env-badge">{environment}</div>
57
+ </div>
58
+ <div className="info-row">
59
+ <div className="info-label">
60
+ <div className="info-icon">⏱️</div>
61
+ Uptime
62
+ </div>
63
+ <div className="info-value">{formatUptime(uptime)}</div>
64
+ </div>
65
+ <div className="info-row">
66
+ <div className="info-label">
67
+ <div className="info-icon">🧠</div>
68
+ Memory Usage
69
+ </div>
70
+ <div className="info-value">{memoryMB} MB</div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ <div className="card">
76
+ <div className="card-title">Services</div>
77
+ <div className="services-grid">
78
+ <div className="service-item">
79
+ <span className={`service-dot ${status === 'UP' ? 'up' : 'down'}`}></span>
80
+ <span className="service-name">Express Server</span>
81
+ </div>
82
+ <div className="service-item">
83
+ <span className={`service-dot ${status === 'UP' ? 'up' : 'down'}`}></span>
84
+ <span className="service-name">Database</span>
85
+ </div>
86
+ <div className="service-item">
87
+ <span className={`service-dot ${status === 'UP' ? 'up' : 'down'}`}></span>
88
+ <span className="service-name">Authentication</span>
89
+ </div>
90
+ <div className="service-item">
91
+ <span className={`service-dot ${status === 'UP' ? 'up' : 'down'}`}></span>
92
+ <span className="service-name">Logging</span>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <div className="footer">
98
+ <a href="https://github.com/glensonansin/lumina" target="_blank">Lumina</a> — Built with Express & Sequelize
99
+ </div>
100
+ </div>
101
+ </>
102
+ );
103
+ }
@@ -0,0 +1,82 @@
1
+ interface WelcomeProps {
2
+ version: string;
3
+ }
4
+
5
+ export default function Welcome({ version }: WelcomeProps) {
6
+ return (
7
+ <>
8
+ <div className="bg-gradient"></div>
9
+ <div className="orb orb-1"></div>
10
+ <div className="orb orb-2"></div>
11
+ <div className="orb orb-3"></div>
12
+ <div className="grid-pattern"></div>
13
+
14
+ <div className="main">
15
+ <div className="logo-wrapper">
16
+ <div className="logo-glow"></div>
17
+ <img src="/img/logo2.png" alt="Lumina Logo" className="logo" />
18
+ </div>
19
+
20
+ <div className="header">
21
+ <h1 className="title">Lumina</h1>
22
+ <p className="subtitle">A production-grade Express starter kit with Sequelize. Featuring OOP architecture, Singleton services, and type-safe database interactions.</p>
23
+ </div>
24
+
25
+ <div className="card">
26
+ <div className="features">
27
+ <div className="feature">
28
+ <div className="feature-icon">🔷</div>
29
+ <div className="feature-text">
30
+ <h3>TypeScript</h3>
31
+ <p>End-to-end type safety</p>
32
+ </div>
33
+ </div>
34
+ <div className="feature">
35
+ <div className="feature-icon">🏗️</div>
36
+ <div className="feature-text">
37
+ <h3>OOP Architecture</h3>
38
+ <p>Clean, scalable patterns</p>
39
+ </div>
40
+ </div>
41
+ <div className="feature">
42
+ <div className="feature-icon">⚡</div>
43
+ <div className="feature-text">
44
+ <h3>Singleton Services</h3>
45
+ <p>Efficient resource management</p>
46
+ </div>
47
+ </div>
48
+ <div className="feature">
49
+ <div className="feature-icon">🗄️</div>
50
+ <div className="feature-text">
51
+ <h3>Database Ready</h3>
52
+ <p>Type-safe DB interactions</p>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <div className="status-bar">
59
+ <div className="status">
60
+ <span className="status-dot"></span>
61
+ Application is Running
62
+ </div>
63
+ </div>
64
+
65
+ <div className="actions">
66
+ <a href="/status" className="btn btn-primary">
67
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>
68
+ System Status
69
+ </a>
70
+ <a href="https://github.com/glensonansin/lumina" target="_blank" className="btn btn-secondary">
71
+ <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.11.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
72
+ Documentation
73
+ </a>
74
+ </div>
75
+
76
+ <div className="footer">
77
+ <a href="https://github.com/glensonansin/lumina" target="_blank">Lumina</a> — Built with Express & Sequelize
78
+ </div>
79
+ </div>
80
+ </>
81
+ );
82
+ }
@@ -0,0 +1,43 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { createInertiaApp } from '@inertiajs/react';
3
+ import '../css/app.css';
4
+
5
+ import type { ComponentType } from 'react';
6
+
7
+ // Catch global errors and render them to the screen
8
+ window.addEventListener('error', (e) => {
9
+ document.body.innerHTML = `<div style="padding: 2rem; color: red; background: white; z-index: 9999; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh;">
10
+ <h1>React Crash!</h1>
11
+ <pre>${e.error?.stack || e.message}</pre>
12
+ </div>`;
13
+ });
14
+ window.addEventListener('unhandledrejection', (e) => {
15
+ document.body.innerHTML = `<div style="padding: 2rem; color: red; background: white; z-index: 9999; position: absolute; top: 0; left: 0; width: 100vw; height: 100vh;">
16
+ <h1>Unhandled Promise Rejection!</h1>
17
+ <pre>${e.reason?.stack || e.reason}</pre>
18
+ </div>`;
19
+ });
20
+
21
+ try {
22
+ const el = document.getElementById('lumina-app');
23
+ if (!el || !el.dataset.page) throw new Error('Missing root element or data-page attribute');
24
+
25
+ const initialPage = JSON.parse(el.dataset.page);
26
+
27
+ createInertiaApp({
28
+ id: 'lumina-app',
29
+ page: initialPage,
30
+ resolve: name => {
31
+ const pages = import.meta.glob<{ default: ComponentType }>('./Pages/**/*.tsx', { eager: true });
32
+ if (!pages[`./Pages/${name}.tsx`]) {
33
+ throw new Error(`Inertia Error: Component './Pages/${name}.tsx' not found!`);
34
+ }
35
+ return pages[`./Pages/${name}.tsx`];
36
+ },
37
+ setup({ el, App, props }) {
38
+ createRoot(el).render(<App {...props} />);
39
+ },
40
+ });
41
+ } catch (e: any) {
42
+ document.body.innerHTML = `<div style="padding: 2rem; color: red; background: white;"><h1>Setup Crash!</h1><pre>${e.stack || e.message}</pre></div>`;
43
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
5
+ "types": ["vite/client"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": false,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "Node",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["./**/*.ts", "./**/*.tsx", "./vite-env.d.ts"]
20
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Lumina</title>
7
+
8
+ <!-- Vite injected assets -->
9
+ {{viteAssets}}
10
+ </head>
11
+ <body>
12
+ <div id="lumina-app" data-page='{{page}}'></div>
13
+ </body>
14
+ </html>
@@ -35,7 +35,11 @@ class UserController {
35
35
  // 1. Get the file path relative to your server
36
36
  const filePath = `uploads/${req.file.filename}`;
37
37
 
38
- // 2. Update the user in the DB (assuming you have req.user from AuthMiddleware)
38
+ if (!req.user) {
39
+ return ApiResponse.error(res, 'Unauthorized', 401);
40
+ }
41
+
42
+ // 2. Update the user in the DB
39
43
  await UserService.updateAvatar(req.user.id, filePath);
40
44
 
41
45
  // 3. Return success
@@ -0,0 +1,94 @@
1
+ import { Request, Response } from 'express';
2
+ import db from '../models/index.js';
3
+ import AuthService from '../services/AuthService.js';
4
+
5
+
6
+
7
+ class WebController {
8
+ /**
9
+ * Render the Welcome page.
10
+ */
11
+ public async index(req: Request, res: Response) {
12
+ return res.inertia('Welcome', {
13
+ version: '1.0.0'
14
+ });
15
+ }
16
+
17
+ /**
18
+ * Render the System Status page.
19
+ */
20
+ public async status(req: Request, res: Response) {
21
+ return res.inertia('Status', {
22
+ status: 'UP',
23
+ environment: process.env.NODE_ENV,
24
+ uptime: process.uptime(),
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Health Check (for container/orchestration probes)
30
+ */
31
+
32
+ public async health(req: Request, res: Response) {
33
+ try {
34
+ await db.sequelize.authenticate();
35
+ res.json({
36
+ status: 'healthy',
37
+ uptime: process.uptime(),
38
+ database: 'connected',
39
+ timestamp: new Date().toISOString(),
40
+ });
41
+ } catch {
42
+ res.status(503).json({
43
+ status: 'unhealthy',
44
+ uptime: process.uptime(),
45
+ database: 'disconnected',
46
+ timestamp: new Date().toISOString(),
47
+ });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Show Login Page
53
+ */
54
+ public async showLogin(req: Request, res: Response) {
55
+ return res.inertia('Auth/Login');
56
+ }
57
+
58
+ /**
59
+ * Handle Login Action
60
+ */
61
+ public async login(req: Request, res: Response) {
62
+ try {
63
+ const { email, password } = req.body;
64
+ const { accessToken, refreshToken } = await AuthService.login(email, password);
65
+
66
+ // Set Access Token (Short-lived)
67
+ res.cookie('access_token', accessToken, {
68
+ httpOnly: true,
69
+ secure: process.env.NODE_ENV === 'production',
70
+ sameSite: 'strict',
71
+ maxAge: 15 * 60 * 1000, // 15 mins
72
+ });
73
+
74
+ // Set Refresh Token (Long-lived)
75
+ res.cookie('refresh_token', refreshToken, {
76
+ httpOnly: true,
77
+ secure: process.env.NODE_ENV === 'production',
78
+ sameSite: 'strict',
79
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
80
+ });
81
+
82
+ return res.redirect('/users');
83
+ } catch (error: any) {
84
+ return res.inertia('Auth/Login', {
85
+ errors: { email: 'Invalid credentials' }
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+
92
+
93
+
94
+ export default new WebController();
@@ -46,6 +46,11 @@ class CreateRefreshTokensTable {
46
46
  allowNull: false,
47
47
  defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
48
48
  },
49
+ deleted_at: {
50
+ type: DataTypes.DATE,
51
+ allowNull: true,
52
+ defaultValue: null,
53
+ },
49
54
  });
50
55
  }
51
56
 
@@ -11,10 +11,10 @@ class ExceptionHandler {
11
11
  */
12
12
  notFound(req: Request, res: Response, next: NextFunction) {
13
13
  if (req.accepts('html') && !req.originalUrl.startsWith('/api')) {
14
- const viewPath = path.join(process.cwd(), 'views', '404.html');
15
- if (fs.existsSync(viewPath)) {
16
- return res.status(404).sendFile(viewPath);
17
- }
14
+ // @ts-ignore - inertia is injected by middleware
15
+ return res.status(404).inertia('Errors/404', {
16
+ url: req.originalUrl
17
+ });
18
18
  }
19
19
 
20
20
  return ApiResponse.error(res, 'Route not found', 404);
@@ -0,0 +1,67 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import jwt from 'jsonwebtoken';
3
+ import ApiResponse from '../utils/ApiResponse.js';
4
+ import env from '../config/env.js';
5
+ import AuthService from '../services/AuthService.js';
6
+ import { UserPayload } from '../types/express/index.js';
7
+
8
+ class ApiAuth {
9
+ /**
10
+ * Handle an incoming request.
11
+ */
12
+ public async handle(req: Request, res: Response, next: NextFunction) {
13
+ const authHeader = req.headers.authorization;
14
+ const accessToken = authHeader?.startsWith('Bearer') ? authHeader.split(' ')[1] : null;
15
+ const refreshToken = req.headers['x-refresh-token'] as string || req.cookies?.refresh_token;
16
+
17
+ if (!accessToken) {
18
+ return ApiResponse.error(res, 'Unauthorized: No token provided', 401);
19
+ }
20
+
21
+ try {
22
+ // Verify access token
23
+ const decoded = jwt.verify(accessToken, env.JWT_SECRET) as UserPayload;
24
+ req.user = decoded;
25
+ next();
26
+ } catch (error: any) {
27
+ // If token is expired and we have a refresh token, try to silent refresh
28
+ if (error.name === 'TokenExpiredError' && refreshToken) {
29
+ return this.silentRefresh(req, res, next, refreshToken);
30
+ }
31
+
32
+ return ApiResponse.error(res, 'Unauthorized: Invalid or expired token', 401);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Attempt to refresh the API token silently.
38
+ */
39
+ private async silentRefresh(req: Request, res: Response, next: NextFunction, token: string) {
40
+ try {
41
+ const { accessToken } = await AuthService.refresh(token);
42
+
43
+ // Verify the new token
44
+ const decoded = jwt.verify(accessToken, env.JWT_SECRET) as UserPayload;
45
+ req.user = decoded;
46
+
47
+ // Add the new token to the response header so the client can update it
48
+ res.setHeader('X-New-Access-Token', accessToken);
49
+
50
+ // If the client sent the refresh token via cookie, update the access token cookie too
51
+ if (req.cookies?.refresh_token) {
52
+ res.cookie('access_token', accessToken, {
53
+ httpOnly: true,
54
+ secure: process.env.NODE_ENV === 'production',
55
+ sameSite: 'strict',
56
+ maxAge: 15 * 60 * 1000,
57
+ });
58
+ }
59
+
60
+ next();
61
+ } catch (error) {
62
+ return ApiResponse.error(res, 'Unauthorized: Refresh token expired or invalid', 401);
63
+ }
64
+ }
65
+ }
66
+
67
+ export default new ApiAuth();
@@ -13,18 +13,19 @@ import crypto from 'crypto';
13
13
  * that the x-csrf-token header matches the csrf_token cookie
14
14
  */
15
15
  class Csrf {
16
- private cookieName = 'csrf_token';
17
- private headerName = 'x-csrf-token';
16
+ private cookieName = 'XSRF-TOKEN';
17
+ private headerName = 'x-xsrf-token';
18
18
  private safeMethods = ['GET', 'HEAD', 'OPTIONS'];
19
19
 
20
+
20
21
  /**
21
22
  * Generate and set CSRF token on safe requests.
22
23
  * Validate token on state-changing requests.
23
24
  */
24
25
  public handle = (req: Request, res: Response, next: NextFunction) => {
25
26
  if (this.safeMethods.includes(req.method)) {
26
- // Generate and set CSRF token cookie on safe methods
27
27
  const token = crypto.randomBytes(32).toString('hex');
28
+
28
29
  res.cookie(this.cookieName, token, {
29
30
  httpOnly: false, // Must be readable by client-side JS
30
31
  secure: process.env.NODE_ENV === 'production',
@@ -41,7 +42,7 @@ class Csrf {
41
42
  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
42
43
  return res.status(403).json({
43
44
  success: false,
44
- message: 'CSRF token validation failed',
45
+ message: 'Invalid CSRF token. Please refresh the page.',
45
46
  });
46
47
  }
47
48
 
@@ -0,0 +1,95 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import inertia from 'inertia-node';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import env from '../config/env.js';
6
+
7
+ export default class InertiaMiddleware {
8
+ public static handle(req: Request, res: Response, next: NextFunction): void {
9
+ const htmlPath = path.join(process.cwd(), 'resources', 'views', 'app.html');
10
+ let template = fs.readFileSync(htmlPath, 'utf-8');
11
+
12
+ // Vite Integration for Dev vs Prod
13
+ let viteAssets = '';
14
+ const isDev = env.NODE_ENV === 'development';
15
+
16
+ if (isDev) {
17
+ viteAssets = `
18
+ <script type="module">
19
+ import RefreshRuntime from 'http://localhost:5173/@react-refresh'
20
+ RefreshRuntime.injectIntoGlobalHook(window)
21
+ window.$RefreshReg$ = () => {}
22
+ window.$RefreshSig$ = () => (type) => type
23
+ window.__vite_plugin_react_preamble_installed__ = true
24
+ </script>
25
+ <script type="module" src="http://localhost:5173/@vite/client"></script>
26
+ <script type="module" src="http://localhost:5173/resources/js/app.tsx"></script>
27
+ `;
28
+ } else {
29
+ try {
30
+ const manifestPath = path.join(process.cwd(), 'public', 'build', '.vite', 'manifest.json');
31
+ if (fs.existsSync(manifestPath)) {
32
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
33
+ const mainAsset = manifest['resources/js/app.tsx'];
34
+
35
+ if (mainAsset) {
36
+ viteAssets = `<script type="module" src="/build/${mainAsset.file}"></script>`;
37
+ if (mainAsset.css) {
38
+ mainAsset.css.forEach((cssFile: string) => {
39
+ viteAssets += `\n<link rel="stylesheet" href="/build/${cssFile}" />`;
40
+ });
41
+ }
42
+ }
43
+ }
44
+ } catch (e) {
45
+ console.error("Failed to load Vite manifest", e);
46
+ }
47
+ }
48
+
49
+ // Replace the vite injected assets
50
+ template = template.replace('{{viteAssets}}', viteAssets);
51
+
52
+ const inertiaMiddleware = inertia((encodedPage: string) => {
53
+ // inertia-node already encodes the page JSON, so we just inject it
54
+ return template.replace('{{page}}', encodedPage);
55
+ }, process.env.INERTIA_VERSION || '1.0.0');
56
+
57
+ return inertiaMiddleware(req, res, () => {
58
+ // Add res.inertia helper to the response object
59
+ res.inertia = (component: string, props: any = {}) => {
60
+ req.Inertia.render({ component, props });
61
+ };
62
+
63
+ // Add res.flash helper
64
+ res.flash = (type: string, message: string) => {
65
+ res.cookie(`flash_${type}`, message, {
66
+ httpOnly: true,
67
+ maxAge: 60000 // 1 minute is enough for a flash message
68
+ });
69
+ };
70
+
71
+ // Share Global Props
72
+ req.Inertia.shareProps({
73
+ auth: {
74
+ user: req.user || null,
75
+ },
76
+ flash: {
77
+ success: req.cookies?.flash_success || null,
78
+ error: req.cookies?.flash_error || null,
79
+ info: req.cookies?.flash_info || null,
80
+ warning: req.cookies?.flash_warning || null,
81
+ },
82
+ errors: req.cookies?.inertia_errors ? JSON.parse(req.cookies.inertia_errors) : {}
83
+ });
84
+
85
+ // Clear flash cookies after sharing
86
+ if (req.cookies?.flash_success) res.clearCookie('flash_success');
87
+ if (req.cookies?.flash_error) res.clearCookie('flash_error');
88
+ if (req.cookies?.flash_info) res.clearCookie('flash_info');
89
+ if (req.cookies?.flash_warning) res.clearCookie('flash_warning');
90
+ if (req.cookies?.inertia_errors) res.clearCookie('inertia_errors');
91
+
92
+ next();
93
+ });
94
+ }
95
+ }
@@ -18,11 +18,11 @@ class Maintenance {
18
18
  res.status(503);
19
19
  res.setHeader('Retry-After', '60');
20
20
 
21
- if (req.accepts('html')) {
22
- const viewPath = path.join(process.cwd(), 'views', 'maintenance.html');
23
- if (fs.existsSync(viewPath)) {
24
- return res.sendFile(viewPath);
25
- }
21
+ if (req.accepts('html') && !req.originalUrl.startsWith('/api')) {
22
+ // @ts-ignore - inertia is injected by middleware
23
+ return res.inertia('Maintenance', {
24
+ message: 'System is currently under maintenance. Please try again later.'
25
+ });
26
26
  }
27
27
 
28
28
  return ApiResponse.error(res, 'System is currently under maintenance. Please try again later.', 503);
@@ -0,0 +1,20 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import ApiResponse from '../utils/ApiResponse.js';
3
+
4
+ class RoleGuard {
5
+ /**
6
+ * Restrict a route to users whose JWT role is in the allowed list.
7
+ * Must run after ApiAuth.handle, which populates req.user.
8
+ */
9
+ public allow(...roles: string[]) {
10
+ return (req: Request, res: Response, next: NextFunction) => {
11
+ if (!req.user || !roles.includes(req.user.role)) {
12
+ return ApiResponse.error(res, 'Forbidden: insufficient permissions', 403);
13
+ }
14
+
15
+ next();
16
+ };
17
+ }
18
+ }
19
+
20
+ export default new RoleGuard();
@@ -9,10 +9,26 @@ class Validator {
9
9
  public validate(schema: ZodType<any>) {
10
10
  return (req: Request, res: Response, next: NextFunction) => {
11
11
  try {
12
- schema.parse(req.body);
12
+ req.body = schema.parse(req.body);
13
13
  next();
14
14
  } catch (error) {
15
15
  if (error instanceof ZodError) {
16
+ // Check if it's an Inertia request or standard Web request
17
+ if (req.header('X-Inertia') || req.accepts('html')) {
18
+ const errors: Record<string, string> = {};
19
+ error.issues.forEach((err) => {
20
+ const path = err.path.join('.');
21
+ errors[path] = err.message;
22
+ });
23
+
24
+ res.cookie('inertia_errors', JSON.stringify(errors), {
25
+ httpOnly: true,
26
+ maxAge: 60000
27
+ });
28
+
29
+ return res.redirect('back');
30
+ }
31
+
16
32
  const formattedErrors = error.issues.map((err) => ({
17
33
  field: err.path.join('.'),
18
34
  message: err.message,