@erwininteractive/mvc 0.6.1 → 0.6.5

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/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ const generateController_1 = require("./generators/generateController");
8
8
  const generateResource_1 = require("./generators/generateResource");
9
9
  const generateWebAuthn_1 = require("./generators/generateWebAuthn");
10
10
  const listRoutes_1 = require("./generators/listRoutes");
11
+ const makeAuth_1 = require("./generators/makeAuth");
11
12
  const program = new commander_1.Command();
12
13
  program
13
14
  .name("erwinmvc")
@@ -18,6 +19,7 @@ Examples:
18
19
  $ erwinmvc init myapp Create a new app
19
20
  $ erwinmvc generate resource Post Generate CRUD for Post
20
21
  $ erwinmvc webauthn Setup passkey authentication
22
+ $ erwinmvc make:auth Generate authentication system
21
23
  $ erwinmvc list:routes Show all routes
22
24
  `);
23
25
  // Init command - scaffold a new application
@@ -116,4 +118,21 @@ program
116
118
  process.exit(1);
117
119
  }
118
120
  });
121
+ // Make auth command - generate complete authentication system
122
+ program
123
+ .command("make:auth")
124
+ .alias("ma")
125
+ .description("Generate a complete authentication system")
126
+ .option("--without-model", "Skip generating User model")
127
+ .option("--session-only", "Only session-based auth (no JWT tokens)")
128
+ .option("--jwt-only", "Only JWT tokens (no sessions)")
129
+ .action(async (options) => {
130
+ try {
131
+ await (0, makeAuth_1.makeAuth)(options);
132
+ }
133
+ catch (err) {
134
+ console.error("Error:", err instanceof Error ? err.message : err);
135
+ process.exit(1);
136
+ }
137
+ });
119
138
  program.parse();
@@ -0,0 +1,34 @@
1
+ import type { ZodError, ZodSchema } from "zod";
2
+ export type ValidationStrategyType = "redirect" | "json";
3
+ /**
4
+ * Zod validation middleware
5
+ * @param schema - Zod schema to validate against
6
+ * @param strategy - 'redirect' (web) or 'json' (API)
7
+ */
8
+ export declare function validate<T>(schema: ZodSchema<T>, strategy?: ValidationStrategyType): (req: any, res: any, next: any) => Promise<any>;
9
+ /**
10
+ * Extract field errors from Zod ZodError
11
+ */
12
+ export declare function getFieldErrors(error: ZodError): Array<{
13
+ field: string;
14
+ message: string;
15
+ }>;
16
+ /**
17
+ * Preserves form input when validation fails
18
+ */
19
+ export declare function getOldInput(req: any): any;
20
+ /**
21
+ * Get validation errors for display
22
+ */
23
+ export declare function getErrors(req: any): Array<{
24
+ field: string;
25
+ message: string;
26
+ }>;
27
+ /**
28
+ * Check if a field has errors
29
+ */
30
+ export declare function hasFieldError(field: string, errors: any): boolean;
31
+ /**
32
+ * Get error message for a specific field
33
+ */
34
+ export declare function getFieldError(field: string, errors: any): string | undefined;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validate = validate;
4
+ exports.getFieldErrors = getFieldErrors;
5
+ exports.getOldInput = getOldInput;
6
+ exports.getErrors = getErrors;
7
+ exports.hasFieldError = hasFieldError;
8
+ exports.getFieldError = getFieldError;
9
+ /**
10
+ * Zod validation middleware
11
+ * @param schema - Zod schema to validate against
12
+ * @param strategy - 'redirect' (web) or 'json' (API)
13
+ */
14
+ function validate(schema, strategy = "redirect") {
15
+ return async (req, res, next) => {
16
+ const result = await schema.safeParseAsync(req.body);
17
+ if (!result.success) {
18
+ const errors = getFieldErrors(result.error);
19
+ const oldInput = req.body;
20
+ if (strategy === "json") {
21
+ return res.status(422).json({ errors, oldInput });
22
+ }
23
+ // Redirect with errors and old input for web forms
24
+ req.flash("errors", errors);
25
+ req.flash("oldInput", oldInput);
26
+ // Get referrer URL or use default
27
+ const redirectUrl = req.headers.referer || req.headers.referrer || "/";
28
+ return res.redirect(redirectUrl);
29
+ }
30
+ req.validatedBody = result.data;
31
+ next();
32
+ };
33
+ }
34
+ /**
35
+ * Extract field errors from Zod ZodError
36
+ */
37
+ function getFieldErrors(error) {
38
+ return error.errors.map((err) => ({
39
+ field: err.path.join("."),
40
+ message: err.message,
41
+ }));
42
+ }
43
+ /**
44
+ * Preserves form input when validation fails
45
+ */
46
+ function getOldInput(req) {
47
+ return req.flash("oldInput")[0] || {};
48
+ }
49
+ /**
50
+ * Get validation errors for display
51
+ */
52
+ function getErrors(req) {
53
+ return req.flash("errors") || [];
54
+ }
55
+ /**
56
+ * Check if a field has errors
57
+ */
58
+ function hasFieldError(field, errors) {
59
+ return errors.some((err) => err.field === field);
60
+ }
61
+ /**
62
+ * Get error message for a specific field
63
+ */
64
+ function getFieldError(field, errors) {
65
+ const error = errors.find((err) => err.field === field);
66
+ return error?.message;
67
+ }
@@ -3,4 +3,5 @@ export type { MvcAppOptions, MvcApp } from "./App";
3
3
  export { getPrismaClient, disconnectPrisma } from "./db";
4
4
  export { hashPassword, verifyPassword, signToken, verifyToken, authenticate, } from "./Auth";
5
5
  export { startRegistration, completeRegistration, startAuthentication, completeAuthentication, getRPConfig, } from "./WebAuthn";
6
+ export { validate, getFieldErrors, getErrors, getOldInput } from "./Validation";
6
7
  export { registerControllers, registerController } from "./Router";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.registerController = exports.registerControllers = exports.getRPConfig = exports.completeAuthentication = exports.startAuthentication = exports.completeRegistration = exports.startRegistration = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
3
+ exports.registerController = exports.registerControllers = exports.getOldInput = exports.getErrors = exports.getFieldErrors = exports.validate = exports.getRPConfig = exports.completeAuthentication = exports.startAuthentication = exports.completeRegistration = exports.startRegistration = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
4
4
  // App factory and server
5
5
  var App_1 = require("./App");
6
6
  Object.defineProperty(exports, "createMvcApp", { enumerable: true, get: function () { return App_1.createMvcApp; } });
@@ -23,6 +23,12 @@ Object.defineProperty(exports, "completeRegistration", { enumerable: true, get:
23
23
  Object.defineProperty(exports, "startAuthentication", { enumerable: true, get: function () { return WebAuthn_1.startAuthentication; } });
24
24
  Object.defineProperty(exports, "completeAuthentication", { enumerable: true, get: function () { return WebAuthn_1.completeAuthentication; } });
25
25
  Object.defineProperty(exports, "getRPConfig", { enumerable: true, get: function () { return WebAuthn_1.getRPConfig; } });
26
+ // Validation
27
+ var Validation_1 = require("./Validation");
28
+ Object.defineProperty(exports, "validate", { enumerable: true, get: function () { return Validation_1.validate; } });
29
+ Object.defineProperty(exports, "getFieldErrors", { enumerable: true, get: function () { return Validation_1.getFieldErrors; } });
30
+ Object.defineProperty(exports, "getErrors", { enumerable: true, get: function () { return Validation_1.getErrors; } });
31
+ Object.defineProperty(exports, "getOldInput", { enumerable: true, get: function () { return Validation_1.getOldInput; } });
26
32
  // Routing
27
33
  var Router_1 = require("./Router");
28
34
  Object.defineProperty(exports, "registerControllers", { enumerable: true, get: function () { return Router_1.registerControllers; } });
@@ -0,0 +1,9 @@
1
+ export interface MakeAuthOptions {
2
+ withoutModel?: boolean;
3
+ sessionOnly?: boolean;
4
+ jwtOnly?: boolean;
5
+ }
6
+ /**
7
+ * Generate a complete authentication system (User model, AuthController, views)
8
+ */
9
+ export declare function makeAuth(options?: MakeAuthOptions): Promise<void>;
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.makeAuth = makeAuth;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const paths_1 = require("./paths");
10
+ /**
11
+ * Generate a complete authentication system (User model, AuthController, views)
12
+ */
13
+ async function makeAuth(options = {}) {
14
+ const targetDir = process.cwd();
15
+ const templateDir = path_1.default.join((0, paths_1.getTemplatesDir)(), "auth");
16
+ console.log("Generating authentication system...");
17
+ // Create auth template files if they don't exist
18
+ createAuthTemplates(templateDir);
19
+ // Copy auth templates
20
+ if (fs_1.default.existsSync(templateDir)) {
21
+ copyDirRecursive(templateDir, targetDir);
22
+ }
23
+ // Update .env with auth configuration
24
+ const envPath = path_1.default.join(targetDir, ".env");
25
+ if (fs_1.default.existsSync(envPath)) {
26
+ const envContent = fs_1.default.readFileSync(envPath, "utf-8");
27
+ if (!envContent.includes("SESSION_SECRET")) {
28
+ fs_1.default.appendFileSync(envPath, "\nSESSION_SECRET=\"change-me-session\"\n");
29
+ }
30
+ }
31
+ // Update .env.example
32
+ const envExamplePath = path_1.default.join(targetDir, ".env.example");
33
+ if (fs_1.default.existsSync(envExamplePath)) {
34
+ const envContent = fs_1.default.readFileSync(envExamplePath, "utf-8");
35
+ if (!envContent.includes("SESSION_SECRET")) {
36
+ fs_1.default.appendFileSync(envExamplePath, "\nSESSION_SECRET=\"change-me-session\"\n");
37
+ }
38
+ }
39
+ console.log("\nAuthentication system generated successfully!");
40
+ console.log(`
41
+ Next steps:
42
+ 1. Run migrations: npm run db:migrate
43
+ 2. Add auth routes to src/server.ts:
44
+ - import * as auth from "./controllers/AuthController";
45
+ - app.get("/login", auth.showLogin);
46
+ - app.post("/login", auth.login);
47
+ - app.get("/register", auth.showRegister);
48
+ - app.post("/register", auth.register);
49
+ - app.post("/logout", auth.logout);
50
+ `);
51
+ // Generate AuthController.ejs files
52
+ const authControllerTemplate = path_1.default.join((0, paths_1.getTemplatesDir)(), "authController.ts.ejs");
53
+ if (fs_1.default.existsSync(authControllerTemplate)) {
54
+ const destPath = path_1.default.join(targetDir, "src", "controllers", "AuthController.ts");
55
+ const content = fs_1.default.readFileSync(authControllerTemplate, "utf-8");
56
+ fs_1.default.writeFileSync(destPath, content);
57
+ console.log("✓ Created src/controllers/AuthController.ts");
58
+ }
59
+ // Generate EJS views
60
+ const viewsDir = path_1.default.join(targetDir, "src", "views", "auth");
61
+ fs_1.default.mkdirSync(viewsDir, { recursive: true });
62
+ const loginView = path_1.default.join((0, paths_1.getTemplatesDir)(), "views/auth/login.ejs");
63
+ if (fs_1.default.existsSync(loginView)) {
64
+ fs_1.default.copyFileSync(loginView, path_1.default.join(viewsDir, "login.ejs"));
65
+ console.log("✓ Created src/views/auth/login.ejs");
66
+ }
67
+ const registerView = path_1.default.join((0, paths_1.getTemplatesDir)(), "views/auth/register.ejs");
68
+ if (fs_1.default.existsSync(registerView)) {
69
+ fs_1.default.copyFileSync(registerView, path_1.default.join(viewsDir, "register.ejs"));
70
+ console.log("✓ Created src/views/auth/register.ejs");
71
+ }
72
+ }
73
+ /**
74
+ * Create auth template files if they don't exist
75
+ */
76
+ function createAuthTemplates(templateDir) {
77
+ fs_1.default.mkdirSync(templateDir, { recursive: true });
78
+ // Create AuthController template
79
+ const controllerTemplate = `import { Request, Response } from "express";
80
+ import { hashPassword, verifyPassword, signToken, verifyToken } from "@erwininteractive/mvc";
81
+ import { getPrismaClient } from "@erwininteractive/mvc";
82
+
83
+ const prisma = getPrismaClient();
84
+
85
+ export async function showLogin(req: Request, res: Response) {
86
+ res.render("auth/login", { title: "Login" });
87
+ }
88
+
89
+ export async function showRegister(req: Request, res: Response) {
90
+ res.render("auth/register", { title: "Register" });
91
+ }
92
+
93
+ export async function login(req: Request, res: Response) {
94
+ const { email, password } = req.body;
95
+
96
+ try {
97
+ const user = await prisma.user.findUnique({
98
+ where: { email }
99
+ });
100
+
101
+ if (!user) {
102
+ return res.render("auth/login", {
103
+ title: "Login",
104
+ error: "Invalid email or password"
105
+ });
106
+ }
107
+
108
+ const isValid = await verifyPassword(password, user.hashedPassword);
109
+ if (!isValid) {
110
+ return res.render("auth/login", {
111
+ title: "Login",
112
+ error: "Invalid email or password"
113
+ });
114
+ }
115
+
116
+ const token = signToken({ userId: user.id, email: user.email });
117
+ res.cookie("token", token, {
118
+ httpOnly: true,
119
+ secure: process.env.NODE_ENV === "production",
120
+ maxAge: 1000 * 60 * 60 * 24 // 24 hours
121
+ });
122
+
123
+ res.redirect("/");
124
+ } catch (err) {
125
+ console.error("Login error:", err);
126
+ res.render("auth/login", {
127
+ title: "Login",
128
+ error: "An error occurred during login"
129
+ });
130
+ }
131
+ }
132
+
133
+ export async function register(req: Request, res: Response) {
134
+ const { email, password, confirmPassword } = req.body;
135
+
136
+ if (password !== confirmPassword) {
137
+ return res.render("auth/register", {
138
+ title: "Register",
139
+ error: "Passwords do not match"
140
+ });
141
+ }
142
+
143
+ try {
144
+ const existingUser = await prisma.user.findUnique({
145
+ where: { email }
146
+ });
147
+
148
+ if (existingUser) {
149
+ return res.render("auth/register", {
150
+ title: "Register",
151
+ error: "Email already in use"
152
+ });
153
+ }
154
+
155
+ const hashedPassword = await hashPassword(password);
156
+
157
+ await prisma.user.create({
158
+ data: {
159
+ email,
160
+ hashedPassword,
161
+ role: "user"
162
+ }
163
+ });
164
+
165
+ res.redirect("/login");
166
+ } catch (err) {
167
+ console.error("Registration error:", err);
168
+ res.render("auth/register", {
169
+ title: "Register",
170
+ error: "An error occurred during registration"
171
+ });
172
+ }
173
+ }
174
+
175
+ export async function logout(req: Request, res: Response) {
176
+ res.clearCookie("token");
177
+ res.redirect("/login");
178
+ }
179
+
180
+ export async function requireAuth(req: Request, res: Response, next: any) {
181
+ const token = req.cookies?.token || req.headers.authorization?.split(" ")[1];
182
+
183
+ if (!token) {
184
+ return res.redirect("/login");
185
+ }
186
+
187
+ try {
188
+ const decoded = verifyToken(token);
189
+ req.user = decoded;
190
+ next();
191
+ } catch {
192
+ res.clearCookie("token");
193
+ res.redirect("/login");
194
+ }
195
+ }
196
+ `;
197
+ fs_1.default.writeFileSync(path_1.default.join(templateDir, "authController.ts.ejs"), controllerTemplate);
198
+ // Create login view
199
+ const loginView = `<!DOCTYPE html>
200
+ <html>
201
+ <head>
202
+ <title><%= title %></title>
203
+ <style>
204
+ body { font-family: sans-serif; max-width: 400px; margin: 50px auto; }
205
+ input { width: 100%; padding: 8px; margin: 8px 0; }
206
+ button { background: #007bff; color: white; padding: 10px; border: none; cursor: pointer; }
207
+ .error { color: red; }
208
+ .link { color: #007bff; }
209
+ </style>
210
+ </head>
211
+ <body>
212
+ <h1><%= title %></h1>
213
+
214
+ <% if (error) { %>
215
+ <p class="error"><%= error %></p>
216
+ <% } %>
217
+
218
+ <form method="POST" action="/login">
219
+ <label>Email</label>
220
+ <input type="email" name="email" required>
221
+
222
+ <label>Password</label>
223
+ <input type="password" name="password" required>
224
+
225
+ <button type="submit">Login</button>
226
+ </form>
227
+
228
+ <p>Don't have an account? <a href="/register" class="link">Register</a></p>
229
+ </body>
230
+ </html>
231
+ `;
232
+ fs_1.default.writeFileSync(path_1.default.join(templateDir, "views/auth/login.ejs.ejs"), loginView);
233
+ // Create register view
234
+ const registerView = `<!DOCTYPE html>
235
+ <html>
236
+ <head>
237
+ <title><%= title %></title>
238
+ <style>
239
+ body { font-family: sans-serif; max-width: 400px; margin: 50px auto; }
240
+ input { width: 100%; padding: 8px; margin: 8px 0; }
241
+ button { background: #28a745; color: white; padding: 10px; border: none; cursor: pointer; }
242
+ .error { color: red; }
243
+ .link { color: #007bff; }
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <h1><%= title %></h1>
248
+
249
+ <% if (error) { %>
250
+ <p class="error"><%= error %></p>
251
+ <% } %>
252
+
253
+ <form method="POST" action="/register">
254
+ <label>Email</label>
255
+ <input type="email" name="email" required>
256
+
257
+ <label>Password</label>
258
+ <input type="password" name="password" required>
259
+
260
+ <label>Confirm Password</label>
261
+ <input type="password" name="confirmPassword" required>
262
+
263
+ <button type="submit">Register</button>
264
+ </form>
265
+
266
+ <p>Already have an account? <a href="/login" class="link">Login</a></p>
267
+ </body>
268
+ </html>
269
+ `;
270
+ fs_1.default.writeFileSync(path_1.default.join(templateDir, "views/auth/register.ejs.ejs"), registerView);
271
+ }
272
+ /**
273
+ * Recursively copy a directory
274
+ */
275
+ function copyDirRecursive(src, dest) {
276
+ if (!fs_1.default.existsSync(src)) {
277
+ return;
278
+ }
279
+ if (!fs_1.default.existsSync(dest)) {
280
+ fs_1.default.mkdirSync(dest, { recursive: true });
281
+ }
282
+ const entries = fs_1.default.readdirSync(src, { withFileTypes: true });
283
+ for (const entry of entries) {
284
+ const srcPath = path_1.default.join(src, entry.name);
285
+ const destPath = path_1.default.join(dest, entry.name);
286
+ if (entry.isDirectory()) {
287
+ copyDirRecursive(srcPath, destPath);
288
+ }
289
+ else if (entry.name.endsWith(".ejs.ejs")) {
290
+ // Remove .ejs.ejs suffix, write render version
291
+ const content = fs_1.default.readFileSync(srcPath, "utf-8");
292
+ const targetPath = destPath.replace(/\.ejs\.ejs$/, ".ejs");
293
+ fs_1.default.writeFileSync(targetPath, content);
294
+ }
295
+ else {
296
+ fs_1.default.copyFileSync(srcPath, destPath);
297
+ }
298
+ }
299
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erwininteractive/mvc",
3
- "version": "0.6.1",
3
+ "version": "0.6.5",
4
4
  "description": "A lightweight, full-featured MVC framework for Node.js with Express, Prisma, and EJS",
5
5
  "main": "dist/framework/index.js",
6
6
  "types": "dist/framework/index.d.ts",
@@ -52,7 +52,8 @@
52
52
  "helmet": "^8.0.0",
53
53
  "jsonwebtoken": "^9.0.2",
54
54
  "prisma": "^6.0.0",
55
- "redis": "^4.7.0"
55
+ "redis": "^4.7.0",
56
+ "zod": "^3.23.8"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/bcryptjs": "^2.4.6",
@@ -12,11 +12,13 @@
12
12
  "db:push": "prisma db push"
13
13
  },
14
14
  "dependencies": {
15
- "@erwininteractive/mvc": "^0.4.0",
16
- "cookie-parser": "^1.4.6"
15
+ "@erwininteractive/mvc": "^0.6.0",
16
+ "cookie-parser": "^1.4.6",
17
+ "zod": "^3.23.8"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/cookie-parser": "^1.4.7",
21
+ "@types/ejs": "^3.1.5",
20
22
  "@types/express": "^5.0.0",
21
23
  "@types/node": "^22.7.5",
22
24
  "tsx": "^4.19.1",