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.
- package/package.json +3 -3
- package/template/.env.example +2 -1
- package/template/nodemon.json +1 -1
- package/template/package.json +26 -15
- package/template/resources/css/app.css +491 -0
- package/template/resources/js/Pages/Errors/404.tsx +32 -0
- package/template/resources/js/Pages/Maintenance.tsx +34 -0
- package/template/resources/js/Pages/Status.tsx +103 -0
- package/template/resources/js/Pages/Welcome.tsx +82 -0
- package/template/resources/js/app.tsx +43 -0
- package/template/resources/js/tsconfig.json +20 -0
- package/template/resources/js/vite-env.d.ts +1 -0
- package/template/resources/views/app.html +14 -0
- package/template/src/controllers/UserController.ts +5 -1
- package/template/src/controllers/WebController.ts +94 -0
- package/template/src/database/migrations/20260420050855-create_refresh_tokens.js +5 -0
- package/template/src/exceptions/Handler.ts +4 -4
- package/template/src/middlewares/ApiAuth.ts +67 -0
- package/template/src/middlewares/Csrf.ts +5 -4
- package/template/src/middlewares/InertiaMiddleware.ts +95 -0
- package/template/src/middlewares/Maintenance.ts +5 -5
- package/template/src/middlewares/RoleGuard.ts +20 -0
- package/template/src/middlewares/Validator.ts +17 -1
- package/template/src/middlewares/WebAuth.ts +122 -0
- package/template/src/models/BaseModel.ts +24 -0
- package/template/src/models/RefreshToken.ts +6 -5
- package/template/src/models/User.ts +3 -5
- package/template/src/routes/api.ts +16 -8
- package/template/src/routes/web.ts +10 -43
- package/template/src/server.ts +115 -0
- package/template/src/types/express/index.d.ts +8 -1
- package/template/src/types/express-inertia.d.ts +28 -0
- package/template/vite.config.ts +20 -0
- package/template/views/404.html +0 -233
- package/template/views/maintenance.html +0 -464
- package/template/views/status.html +0 -545
- 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
|
-
|
|
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();
|
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 = '
|
|
17
|
-
private headerName = 'x-
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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,
|