easy-starter 1.2.1 → 2.1.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/README.md +33 -80
- package/bin/index.js +251 -49
- package/package.json +20 -11
- package/template/.cursorrules +42 -10
- package/template/README.md +129 -25
- package/template/env +0 -1
- package/template/gitignore +2 -1
- package/template/index.html +15 -0
- package/template/package.json +14 -8
- package/template/{src → public}/site.webmanifest +6 -12
- package/template/scripts/deploy.ts +3 -2
- package/template/server/index.tsx +264 -56
- package/template/server/mail.tsx +120 -0
- package/template/src/App.tsx +33 -7
- package/template/src/Router.tsx +13 -4
- package/template/src/components/Img.tsx +127 -0
- package/template/src/index.tsx +28 -1
- package/template/src/pages/Index.tsx +36 -6
- package/template/src/pages/NotFound.tsx +1 -1
- package/template/src/pages/ResetPassword.tsx +124 -0
- package/template/src/pages/admin/Admin.tsx +332 -85
- package/template/src/pages/admin/AnalyticsTab.tsx +71 -0
- package/template/src/pages/admin/ErrorsTab.tsx +46 -0
- package/template/src/pages/admin/UsersTab.tsx +54 -0
- package/template/src/services/api.ts +5 -7
- package/template/src/services/common.ts +34 -1
- package/template/src/styles.css +667 -0
- package/template/tsconfig.json +7 -2
- package/template/types.ts +44 -3
- package/template/vite.config.ts +29 -0
- package/template/.parcelrc +0 -6
- package/template/src/images/icon.png +0 -0
- package/template/src/index.html +0 -16
- package/template/src/styles.scss +0 -292
|
@@ -5,13 +5,31 @@ import cors from "cors";
|
|
|
5
5
|
import express from "express";
|
|
6
6
|
|
|
7
7
|
import easyDBNode from "easy-db-node";
|
|
8
|
-
import { renderToHTML } from "ssr-hook/server";
|
|
9
8
|
import { setAPIBackend } from "typed-client-server-api";
|
|
10
9
|
|
|
10
|
+
// --- SSR START ---
|
|
11
|
+
import { renderToHTML } from "ssr-hook/server";
|
|
12
|
+
// --- SSR END ---
|
|
13
|
+
|
|
14
|
+
// --- ANALYTICS START ---
|
|
11
15
|
import { postEasyAnalytics, getEasyAnalytics } from "easy-analytics/server";
|
|
16
|
+
// --- ANALYTICS END ---
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
// --- AUTH START ---
|
|
19
|
+
import { EasyLoginServer } from "easy-user-auth/server";
|
|
20
|
+
import { sendMailForgottenPassword } from "./mail";
|
|
21
|
+
import { UserProfile } from "../types";
|
|
22
|
+
// --- AUTH END ---
|
|
14
23
|
|
|
24
|
+
// --- OGIMAGE START ---
|
|
25
|
+
import puppeteer from "puppeteer";
|
|
26
|
+
// --- OGIMAGE END ---
|
|
27
|
+
|
|
28
|
+
// --- RESIZER START ---
|
|
29
|
+
import { expressImageResizer } from "easy-image-resizer";
|
|
30
|
+
// --- RESIZER END ---
|
|
31
|
+
|
|
32
|
+
import App from "../src/App";
|
|
15
33
|
import { API, Database } from "../types";
|
|
16
34
|
|
|
17
35
|
const PORT = process.env.SERVER_PORT || 1111;
|
|
@@ -20,7 +38,7 @@ const LOCALHOST = `http://localhost:${PORT}`;
|
|
|
20
38
|
// The ORIGIN is used for SEO and by ssr-hook to know where the requests are coming from.
|
|
21
39
|
const ORIGIN = process.env.NODE_ENV === "development"
|
|
22
40
|
? LOCALHOST
|
|
23
|
-
: process.env.SERVER_URL;
|
|
41
|
+
: process.env.SERVER_URL || LOCALHOST;
|
|
24
42
|
|
|
25
43
|
const INDEX_HTML_PATH = resolve(__dirname, "..", "dist", "index.html");
|
|
26
44
|
|
|
@@ -36,23 +54,56 @@ const app = express();
|
|
|
36
54
|
|
|
37
55
|
if (process.env.NODE_ENV === "development") {
|
|
38
56
|
console.log("Starting development server...");
|
|
39
|
-
app.use(cors(
|
|
57
|
+
app.use(cors({
|
|
58
|
+
origin: ["http://localhost:5173", "http://localhost:1111"],
|
|
59
|
+
credentials: true
|
|
60
|
+
}));
|
|
61
|
+
} else {
|
|
62
|
+
app.use(cors({
|
|
63
|
+
origin: ORIGIN,
|
|
64
|
+
credentials: true
|
|
65
|
+
}));
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
//
|
|
43
|
-
app.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
// Parse incoming JSON payloads.
|
|
69
|
+
app.use(express.json({ limit: "1MB" }));
|
|
70
|
+
|
|
71
|
+
// --- AUTH START ---
|
|
72
|
+
const authServer = new EasyLoginServer<UserProfile>({
|
|
73
|
+
jwtSecret: process.env.JWT_SECRET || "fallback-secret-key-change-me",
|
|
74
|
+
secureCookies: process.env.NODE_ENV === "production",
|
|
75
|
+
db: {
|
|
76
|
+
insertUser: async (user: any) => insert("user", user),
|
|
77
|
+
getUserById: async (id: string) => select("user", id),
|
|
78
|
+
getUserByMail: async (mail: string) => {
|
|
79
|
+
const users = await selectArray("user");
|
|
80
|
+
return users.find((u: any) => u.mail.toLowerCase() === mail.toLowerCase()) || null;
|
|
81
|
+
},
|
|
82
|
+
getUserByRecoveryToken: async (token: string) => {
|
|
83
|
+
const users = await selectArray("user");
|
|
84
|
+
return users.find((u: any) => u.passwordRecovery?.token === token) || null;
|
|
85
|
+
},
|
|
86
|
+
updateUser: async (id: string, user: any) => {
|
|
87
|
+
await update("user", id, user);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
mailSender: async (lang: string, mailTo: string, token: string) => {
|
|
91
|
+
await sendMailForgottenPassword(lang, mailTo, token, ORIGIN);
|
|
92
|
+
},
|
|
54
93
|
});
|
|
55
94
|
|
|
95
|
+
// Register all user authentication routes (/api/login, /api/register, /api/user, etc.)
|
|
96
|
+
authServer.registerExpressRoutes(app);
|
|
97
|
+
// --- AUTH END ---
|
|
98
|
+
|
|
99
|
+
// --- RESIZER START ---
|
|
100
|
+
// Serve and dynamically resize images from public directory with auto-caching
|
|
101
|
+
app.use(expressImageResizer({
|
|
102
|
+
sourceDir: "./public",
|
|
103
|
+
cacheDir: "./cache/images",
|
|
104
|
+
}));
|
|
105
|
+
// --- RESIZER END ---
|
|
106
|
+
|
|
56
107
|
// Serve static files generated by the bundler (JS, CSS)
|
|
57
108
|
app.use(express.static("dist"));
|
|
58
109
|
// Serve any public assets (like images, fonts)
|
|
@@ -60,41 +111,95 @@ app.use(express.static("public"));
|
|
|
60
111
|
// Serve the database files (e.g. uploaded images or PDF files from easy-db-node)
|
|
61
112
|
app.use("/files", express.static("easy-db-files"));
|
|
62
113
|
|
|
114
|
+
// ------------------------------ og:image -------------------------------------
|
|
115
|
+
// --- OGIMAGE START ---
|
|
116
|
+
app.get("/og-image.png", async (req, res) => {
|
|
117
|
+
const path = req.query.path as string;
|
|
118
|
+
if (!path) {
|
|
119
|
+
return res.sendFile(resolve(__dirname, "..", "public", "images", "icon.png"));
|
|
120
|
+
}
|
|
63
121
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
res.send(`User-agent: *\nAllow: /\n\nSitemap: ${ORIGIN}/sitemap.xml`);
|
|
69
|
-
});
|
|
122
|
+
const image = await select("og-image", path);
|
|
123
|
+
if (image) {
|
|
124
|
+
return res.redirect(302, image.file.url);
|
|
125
|
+
}
|
|
70
126
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
try {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const browser = await puppeteer.launch({
|
|
130
|
+
headless: true,
|
|
131
|
+
args: [
|
|
132
|
+
'--no-sandbox',
|
|
133
|
+
'--disable-setuid-sandbox',
|
|
134
|
+
'--disable-gpu',
|
|
135
|
+
'--disable-dev-shm-usage',
|
|
136
|
+
'--no-first-run',
|
|
137
|
+
'--no-zygote',
|
|
138
|
+
'--single-process',
|
|
139
|
+
]
|
|
140
|
+
});
|
|
81
141
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
});
|
|
142
|
+
const page = await browser.newPage();
|
|
143
|
+
// --- SSR START ---
|
|
144
|
+
await page.setJavaScriptEnabled(false);
|
|
145
|
+
// --- SSR END ---
|
|
146
|
+
// --- NO_SSR START ---
|
|
147
|
+
await page.setJavaScriptEnabled(true);
|
|
148
|
+
// --- NO_SSR END ---
|
|
149
|
+
await page.setViewport({ width: 600, height: 500 });
|
|
91
150
|
|
|
151
|
+
// Render target page
|
|
152
|
+
await page.goto(`${LOCALHOST}${path}`, { waitUntil: 'networkidle0' });
|
|
153
|
+
const base64 = await page.screenshot({ type: "png", encoding: "base64" });
|
|
154
|
+
await browser.close();
|
|
92
155
|
|
|
93
|
-
|
|
94
|
-
|
|
156
|
+
await update("og-image", path, {
|
|
157
|
+
dateCreated: new Date().toISOString(),
|
|
158
|
+
file: {
|
|
159
|
+
type: "EASY_DB_FILE",
|
|
160
|
+
url: `data:image/png;base64,${base64}`,
|
|
161
|
+
},
|
|
162
|
+
} as any);
|
|
163
|
+
|
|
164
|
+
console.log(`Render og:image for ${path} took ${Date.now() - now} ms.`);
|
|
165
|
+
|
|
166
|
+
const img = await select("og-image", path);
|
|
167
|
+
return res.redirect(302, img!.file.url);
|
|
168
|
+
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.error("og:image rendering error:", e);
|
|
171
|
+
return res.sendFile(resolve(__dirname, "..", "public", "images", "icon.png"));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// --- OGIMAGE END ---
|
|
175
|
+
|
|
176
|
+
// ---------------------------- SSR for index ----------------------------------
|
|
177
|
+
// --- SSR START ---
|
|
178
|
+
app.get(["/", "/index.html"], async (req, res) => {
|
|
179
|
+
try {
|
|
180
|
+
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
181
|
+
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
182
|
+
res.set("Content-Type", "text/html");
|
|
183
|
+
res.send(html);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error("SSR error:", e);
|
|
186
|
+
res.status(500).json({ message: "Internal server error" });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// --- SSR END ---
|
|
190
|
+
// --- NO_SSR START ---
|
|
191
|
+
app.get(["/", "/index.html"], async (req, res) => {
|
|
192
|
+
try {
|
|
193
|
+
res.sendFile(INDEX_HTML_PATH);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error("Error serving index.html:", e);
|
|
196
|
+
res.status(500).json({ message: "Internal server error" });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// --- NO_SSR END ---
|
|
95
200
|
|
|
96
201
|
// --------------------------- Easy analytics ----------------------------------
|
|
97
|
-
//
|
|
202
|
+
// --- ANALYTICS START ---
|
|
98
203
|
app.post("/api/easy-analytics", async (req, res) => {
|
|
99
204
|
const userAgent = req.headers['user-agent'] || "";
|
|
100
205
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || "";
|
|
@@ -107,10 +212,9 @@ app.post("/api/easy-analytics", async (req, res) => {
|
|
|
107
212
|
res.status(400).json({ message: e.message || "Bad request" });
|
|
108
213
|
}
|
|
109
214
|
});
|
|
215
|
+
// --- ANALYTICS END ---
|
|
110
216
|
|
|
111
217
|
// --------------------------------- API ---------------------------------------
|
|
112
|
-
// Set up our type-safe API backend. All functions defined here map directly to the
|
|
113
|
-
// API type defined in types.d.ts, and can be called from the frontend using by api., useApi., or useSSRApi.
|
|
114
218
|
setAPIBackend<API>(app, {
|
|
115
219
|
async getItems() {
|
|
116
220
|
return await selectArray("item");
|
|
@@ -118,30 +222,130 @@ setAPIBackend<API>(app, {
|
|
|
118
222
|
async getItem({ id }) {
|
|
119
223
|
const item = await select("item", id);
|
|
120
224
|
if (!item) throw new Error("Item not found");
|
|
121
|
-
return item;
|
|
225
|
+
return item;
|
|
122
226
|
},
|
|
123
227
|
async addItem({ name, value }) {
|
|
124
|
-
const id = await insert("item", { name, value });
|
|
228
|
+
const id = await insert("item", { name, value } as any);
|
|
125
229
|
return id;
|
|
126
230
|
},
|
|
127
231
|
async removeItem({ id }) {
|
|
128
232
|
await remove("item", id);
|
|
129
233
|
return true;
|
|
130
234
|
},
|
|
131
|
-
|
|
132
|
-
|
|
235
|
+
|
|
236
|
+
/* --- ADMIN_NO_AUTH START --- */
|
|
237
|
+
async getEasyAnalyticsData({ password, month }: { password?: string; month: string }) {
|
|
133
238
|
const validPassword = process.env.ADMIN_PASSWORD;
|
|
134
239
|
if (!validPassword || password !== validPassword) {
|
|
135
240
|
throw new Error("Invalid password or server error");
|
|
136
241
|
}
|
|
242
|
+
// --- ANALYTICS START ---
|
|
137
243
|
return await getEasyAnalytics(month);
|
|
138
|
-
|
|
139
|
-
|
|
244
|
+
// --- ANALYTICS END ---
|
|
245
|
+
// --- NO_ANALYTICS START ---
|
|
246
|
+
return [];
|
|
247
|
+
// --- NO_ANALYTICS END ---
|
|
248
|
+
},
|
|
249
|
+
/* --- ADMIN_NO_AUTH END --- */
|
|
250
|
+
|
|
251
|
+
// --- ADMIN_WITH_AUTH START ---
|
|
252
|
+
async getEasyAnalyticsData({ month }: { month: string }, req?: any, res?: any) {
|
|
253
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
254
|
+
const user = await select("user", session.userId);
|
|
255
|
+
if (!user || user.role !== "admin") {
|
|
256
|
+
throw new Error("Forbidden");
|
|
257
|
+
}
|
|
258
|
+
// --- ANALYTICS START ---
|
|
259
|
+
return await getEasyAnalytics(month);
|
|
260
|
+
// --- ANALYTICS END ---
|
|
261
|
+
// --- NO_ANALYTICS START ---
|
|
262
|
+
return [];
|
|
263
|
+
// --- NO_ANALYTICS END ---
|
|
264
|
+
},
|
|
265
|
+
// --- ADMIN_WITH_AUTH END ---
|
|
266
|
+
|
|
267
|
+
/* --- ADMIN_ERRORS_NO_AUTH START --- */
|
|
268
|
+
async getEasyAnalyticsErrors({ password, month }: { password?: string; month: string }) {
|
|
269
|
+
const validPassword = process.env.ADMIN_PASSWORD;
|
|
270
|
+
if (!validPassword || password !== validPassword) {
|
|
271
|
+
throw new Error("Invalid password or server error");
|
|
272
|
+
}
|
|
273
|
+
return await selectArray(`easyAnalyticsError-${month}` as any);
|
|
274
|
+
},
|
|
275
|
+
/* --- ADMIN_ERRORS_NO_AUTH END --- */
|
|
276
|
+
|
|
277
|
+
// --- ADMIN_ERRORS_WITH_AUTH START ---
|
|
278
|
+
async getEasyAnalyticsErrors({ month }: { month: string }, req?: any, res?: any) {
|
|
279
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
280
|
+
const user = await select("user", session.userId);
|
|
281
|
+
if (!user || user.role !== "admin") {
|
|
282
|
+
throw new Error("Forbidden");
|
|
283
|
+
}
|
|
284
|
+
return await selectArray(`easyAnalyticsError-${month}` as any);
|
|
285
|
+
},
|
|
286
|
+
// --- ADMIN_ERRORS_WITH_AUTH END ---
|
|
287
|
+
|
|
288
|
+
// --- ADMIN_USERS START ---
|
|
289
|
+
async getUsers(_params: {}, req: any, res: any) {
|
|
290
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
291
|
+
const user = await select("user", session.userId);
|
|
292
|
+
if (!user || user.role !== "admin") {
|
|
293
|
+
throw new Error("Forbidden");
|
|
294
|
+
}
|
|
295
|
+
const allUsers = await selectArray("user");
|
|
296
|
+
return allUsers.map((u: any) => ({
|
|
297
|
+
_id: u._id,
|
|
298
|
+
email: u.mail,
|
|
299
|
+
name: u.name || u.profile?.name || "",
|
|
300
|
+
role: u.profile?.role || u.role || "user"
|
|
301
|
+
}));
|
|
302
|
+
},
|
|
303
|
+
async updateUserRole({ userId, role }: { userId: string; role: "admin" | "user" }, req: any, res: any) {
|
|
304
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
305
|
+
const user = await select("user", session.userId);
|
|
306
|
+
if (!user || user.role !== "admin") {
|
|
307
|
+
throw new Error("Forbidden");
|
|
308
|
+
}
|
|
309
|
+
const targetUser = await select("user", userId);
|
|
310
|
+
if (!targetUser) {
|
|
311
|
+
throw new Error("User not found");
|
|
312
|
+
}
|
|
313
|
+
targetUser.role = role;
|
|
314
|
+
(targetUser as any).profile = (targetUser as any).profile || { name: (targetUser as any).name || "", role: "user" };
|
|
315
|
+
(targetUser as any).profile.role = role;
|
|
316
|
+
await update("user", userId, targetUser);
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
// --- ADMIN_USERS END ---
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// --------------------------------- SEO ---------------------------------------
|
|
323
|
+
app.get("/robots.txt", (_: express.Request, res: express.Response) => {
|
|
324
|
+
res.type("text/plain");
|
|
325
|
+
res.send(`User-agent: *\nAllow: /\n\nSitemap: ${ORIGIN}/sitemap.xml`);
|
|
140
326
|
});
|
|
141
327
|
|
|
328
|
+
app.get("/sitemap.xml", async (_: express.Request, res: express.Response) => {
|
|
329
|
+
const sitemap = [{
|
|
330
|
+
url: ORIGIN + "/",
|
|
331
|
+
lastModified: new Date().toISOString().slice(0, 10),
|
|
332
|
+
changeFrequency: "weekly",
|
|
333
|
+
priority: 1,
|
|
334
|
+
}];
|
|
335
|
+
|
|
336
|
+
res.set("Content-Type", "text/xml");
|
|
337
|
+
res.send(`<?xml version="1.0" encoding="UTF-8" ?>
|
|
338
|
+
<urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${sitemap.map(s => `<url>
|
|
339
|
+
<loc>${s.url}</loc>
|
|
340
|
+
<lastmod>${s.lastModified}</lastmod>
|
|
341
|
+
${s.changeFrequency ? `<changefreq>${s.changeFrequency}</changefreq>` : ""}
|
|
342
|
+
<priority>${s.priority}</priority>
|
|
343
|
+
</url>`).join("\n")}</urlset>`);
|
|
344
|
+
});
|
|
142
345
|
|
|
143
346
|
// --------------------------------- SSR ---------------------------------------
|
|
144
|
-
|
|
347
|
+
// --- SSR START ---
|
|
348
|
+
app.get(/.*/, async (req: express.Request, res: express.Response) => {
|
|
145
349
|
try {
|
|
146
350
|
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
147
351
|
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
@@ -149,11 +353,15 @@ app.get(/.*/, async (req, res) => {
|
|
|
149
353
|
res.send(html);
|
|
150
354
|
} catch (e) {
|
|
151
355
|
console.error("SSR error:", e);
|
|
152
|
-
res.status(500);
|
|
153
|
-
res.json({ message: "Internal server error" });
|
|
356
|
+
res.status(500).json({ message: "Internal server error" });
|
|
154
357
|
}
|
|
155
358
|
});
|
|
156
|
-
|
|
359
|
+
// --- SSR END ---
|
|
360
|
+
// --- NO_SSR START ---
|
|
361
|
+
app.get(/.*/, async (req: express.Request, res: express.Response) => {
|
|
362
|
+
res.sendFile(INDEX_HTML_PATH);
|
|
363
|
+
});
|
|
364
|
+
// --- NO_SSR END ---
|
|
157
365
|
|
|
158
366
|
// Start listening for incoming requests.
|
|
159
367
|
app.listen(PORT, () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { renderToString } from "react-dom/server";
|
|
4
|
+
|
|
5
|
+
const mailHost = process.env.MAIL_SMTP;
|
|
6
|
+
const mailUser = process.env.MAIL_USER;
|
|
7
|
+
const mailPort = Number(process.env.MAIL_PORT || 587);
|
|
8
|
+
const mailPass = process.env.MAIL_PASSWORD;
|
|
9
|
+
|
|
10
|
+
const transporter = (mailHost && mailUser && mailPass) ? nodemailer.createTransport({
|
|
11
|
+
host: mailHost,
|
|
12
|
+
port: mailPort,
|
|
13
|
+
secure: mailPort === 465,
|
|
14
|
+
auth: {
|
|
15
|
+
user: mailUser,
|
|
16
|
+
pass: mailPass,
|
|
17
|
+
},
|
|
18
|
+
}) : null;
|
|
19
|
+
|
|
20
|
+
async function sendMail(mailTo: string, subject: string, html: string) {
|
|
21
|
+
if (!transporter) {
|
|
22
|
+
console.warn("\n⚠️ SMTP is not configured. Email was not sent.");
|
|
23
|
+
console.log(`To: ${mailTo}`);
|
|
24
|
+
console.log(`Subject: ${subject}`);
|
|
25
|
+
console.log(`Content HTML preview:\n${html}\n`);
|
|
26
|
+
return true; // Simulate success in dev if not configured
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const info = await transporter.sendMail({
|
|
31
|
+
from: `"My App" <${mailUser}>`,
|
|
32
|
+
to: mailTo,
|
|
33
|
+
subject,
|
|
34
|
+
html,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return info.accepted.includes(mailTo);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error("Error sending email:", err);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mailTranslations: Record<string, {
|
|
45
|
+
subject: string;
|
|
46
|
+
title: string;
|
|
47
|
+
instruction: string;
|
|
48
|
+
button: string;
|
|
49
|
+
fallback: string;
|
|
50
|
+
disclaimer: string;
|
|
51
|
+
signoff: string;
|
|
52
|
+
}> = {
|
|
53
|
+
cs: {
|
|
54
|
+
subject: "Zapomenuté heslo",
|
|
55
|
+
title: "Zapomenuté heslo",
|
|
56
|
+
instruction: "Pro nastavení nového hesla klikněte na následující tlačítko:",
|
|
57
|
+
button: "Změnit heslo",
|
|
58
|
+
fallback: "Pokud tlačítko nefunguje, zkopírujte tento odkaz do prohlížeče:",
|
|
59
|
+
disclaimer: "Pokud jste o změnu hesla nežádali, tento e-mail prosím ignorujte.",
|
|
60
|
+
signoff: "S pozdravem,",
|
|
61
|
+
},
|
|
62
|
+
en: {
|
|
63
|
+
subject: "Forgotten Password",
|
|
64
|
+
title: "Forgotten Password",
|
|
65
|
+
instruction: "To set your new password, click the following button:",
|
|
66
|
+
button: "Change password",
|
|
67
|
+
fallback: "If the button does not work, try copying this link into your browser:",
|
|
68
|
+
disclaimer: "If you did not request a password change, please ignore this email.",
|
|
69
|
+
signoff: "Kind regards,",
|
|
70
|
+
},
|
|
71
|
+
es: {
|
|
72
|
+
subject: "Contraseña olvidada",
|
|
73
|
+
title: "Contraseña olvidada",
|
|
74
|
+
instruction: "Para configurar tu nueva contraseña, haz clic en el siguiente botón:",
|
|
75
|
+
button: "Cambiar contraseña",
|
|
76
|
+
fallback: "Si el botón no funciona, prueba copiando este enlace en tu navegador:",
|
|
77
|
+
disclaimer: "Si no solicitaste un cambio de contraseña, ignora este correo electrónico.",
|
|
78
|
+
signoff: "Saludos cordiales,",
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export async function sendMailForgottenPassword(lang: string, mailTo: string, token: string, origin: string) {
|
|
83
|
+
const link = `${origin}/reset-password?token=${encodeURIComponent(token)}`;
|
|
84
|
+
const t = mailTranslations[lang] || mailTranslations.en;
|
|
85
|
+
|
|
86
|
+
const isMailSent = await sendMail(mailTo, t.subject, renderToString(
|
|
87
|
+
<div style={{ fontFamily: "sans-serif", padding: 20, color: "#333" }}>
|
|
88
|
+
<div style={{ backgroundColor: "#333", padding: "20px", color: "#fff", fontSize: "24px", fontWeight: "bold", textAlign: "center", borderRadius: "8px 8px 0 0" }}>
|
|
89
|
+
My App
|
|
90
|
+
</div>
|
|
91
|
+
<div style={{ padding: "20px", border: "1px solid #ddd", borderRadius: "0 0 8px 8px", backgroundColor: "#fafafa" }}>
|
|
92
|
+
<h2>{t.title}</h2>
|
|
93
|
+
<p>{t.instruction}</p>
|
|
94
|
+
<a href={link} target="_blank" rel="noopener noreferrer" style={{
|
|
95
|
+
display: "inline-block",
|
|
96
|
+
margin: "16px 0px",
|
|
97
|
+
padding: "12px 24px",
|
|
98
|
+
borderRadius: "6px",
|
|
99
|
+
backgroundColor: "#6366f1",
|
|
100
|
+
color: "#ffffff",
|
|
101
|
+
textDecoration: "none",
|
|
102
|
+
fontWeight: "bold",
|
|
103
|
+
}}>
|
|
104
|
+
{t.button}
|
|
105
|
+
</a>
|
|
106
|
+
<p style={{ fontSize: "12px", color: "#777" }}>{t.fallback}</p>
|
|
107
|
+
<pre style={{ backgroundColor: "#eee", padding: 10, borderRadius: 4, overflowX: "auto", fontSize: "11px" }}>{link}</pre>
|
|
108
|
+
<p style={{ fontSize: "12px", color: "#999", marginTop: 20 }}>{t.disclaimer}</p>
|
|
109
|
+
<p style={{ borderTop: "1px solid #eee", paddingTop: 15, marginTop: 20 }}>
|
|
110
|
+
{t.signoff}<br />
|
|
111
|
+
<strong>My App Team</strong>
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
));
|
|
116
|
+
|
|
117
|
+
if (!isMailSent) {
|
|
118
|
+
throw new Error("Failed to send forgotten password email.");
|
|
119
|
+
}
|
|
120
|
+
}
|
package/template/src/App.tsx
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
|
-
import
|
|
1
|
+
import React from "react";
|
|
2
2
|
import { RouterProvider } from "easy-page-router";
|
|
3
3
|
|
|
4
4
|
import Router from "./Router";
|
|
5
5
|
|
|
6
|
+
// --- ANALYTICS START ---
|
|
7
|
+
import { init } from "easy-analytics/client";
|
|
8
|
+
|
|
6
9
|
// Initialize analytics. It sends data to our backend endpoint.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
// In development, Vite proxies this relative path to the Express server.
|
|
11
|
+
init("/api/easy-analytics");
|
|
12
|
+
// --- ANALYTICS END ---
|
|
13
|
+
|
|
14
|
+
// --- AUTH START ---
|
|
15
|
+
import { UserProvider } from "easy-user-auth/react";
|
|
16
|
+
import { UserProfile } from "../types";
|
|
17
|
+
// --- AUTH END ---
|
|
10
18
|
|
|
11
19
|
export default function App() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
// --- AUTH START ---
|
|
21
|
+
const defaultUser: UserProfile = {
|
|
22
|
+
name: "",
|
|
23
|
+
role: "user",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<UserProvider<UserProfile> defaultUser={defaultUser}>
|
|
28
|
+
<RouterProvider>
|
|
29
|
+
<Router />
|
|
30
|
+
</RouterProvider>
|
|
31
|
+
</UserProvider>
|
|
32
|
+
);
|
|
33
|
+
// --- AUTH END ---
|
|
34
|
+
// --- NO_AUTH START ---
|
|
35
|
+
return (
|
|
36
|
+
<RouterProvider>
|
|
37
|
+
<Router />
|
|
38
|
+
</RouterProvider>
|
|
39
|
+
);
|
|
40
|
+
// --- NO_AUTH END ---
|
|
15
41
|
}
|
package/template/src/Router.tsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
1
2
|
import { Router } from "easy-page-router/react";
|
|
2
3
|
|
|
3
4
|
import Index from "./pages/Index";
|
|
4
5
|
import NotFound from "./pages/NotFound";
|
|
5
|
-
// --- ADMIN START ---
|
|
6
6
|
|
|
7
|
+
// --- ADMIN START ---
|
|
7
8
|
import { lazy, Suspense } from "react";
|
|
8
9
|
const Admin = lazy(() => import("./pages/admin/Admin"));
|
|
9
10
|
// --- ADMIN END ---
|
|
10
11
|
|
|
12
|
+
// --- AUTH START ---
|
|
13
|
+
import ResetPassword from "./pages/ResetPassword";
|
|
14
|
+
// --- AUTH END ---
|
|
15
|
+
|
|
11
16
|
export default function AppRouter() {
|
|
12
17
|
return <Router
|
|
13
|
-
renderPage={({ path }) => {
|
|
18
|
+
renderPage={({ path }: { path: string[] }) => {
|
|
14
19
|
// The 'path' variable is an array of strings representing the URL segments.
|
|
15
20
|
// For example, "/users/123" would be ["users", "123"].
|
|
16
21
|
|
|
@@ -19,12 +24,16 @@ export default function AppRouter() {
|
|
|
19
24
|
|
|
20
25
|
// --- ADMIN START ---
|
|
21
26
|
if (path[0] === 'admin') return (
|
|
22
|
-
<Suspense fallback={<div
|
|
23
|
-
<Admin />
|
|
27
|
+
<Suspense fallback={<div className="loading-fallback">Loading admin...</div>}>
|
|
28
|
+
<Admin path={path} />
|
|
24
29
|
</Suspense>
|
|
25
30
|
);
|
|
26
31
|
// --- ADMIN END ---
|
|
27
32
|
|
|
33
|
+
// --- AUTH START ---
|
|
34
|
+
if (path[0] === 'reset-password') return <ResetPassword />;
|
|
35
|
+
// --- AUTH END ---
|
|
36
|
+
|
|
28
37
|
// You can add more routes here, for example:
|
|
29
38
|
// if (path[0] === 'about') return <AboutPage />;
|
|
30
39
|
// if (path[0] === 'users' && path[1]) return <UserPage id={path[1]} />;
|