easy-starter 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/README.md +32 -80
- package/bin/index.js +219 -44
- package/package.json +20 -11
- package/template/.cursorrules +23 -0
- package/template/.github/workflows/deploy.yml +3 -0
- package/template/README.md +81 -24
- package/template/env +3 -2
- package/template/gitignore +1 -1
- package/template/index.html +15 -0
- package/template/package.json +8 -9
- package/template/public/images/icon-16.png +0 -0
- package/template/public/images/icon-180.png +0 -0
- package/template/public/images/icon-192.png +0 -0
- package/template/public/images/icon-32.png +0 -0
- package/template/public/images/icon-512.png +0 -0
- package/template/{src → public}/site.webmanifest +6 -12
- package/template/scripts/deploy.ts +3 -2
- package/template/server/index.tsx +248 -61
- package/template/server/mail.tsx +120 -0
- package/template/src/App.tsx +33 -7
- package/template/src/Router.tsx +11 -2
- package/template/src/index.tsx +28 -1
- package/template/src/pages/Index.tsx +45 -6
- package/template/src/pages/ResetPassword.tsx +126 -0
- package/template/src/pages/admin/Admin.tsx +360 -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 +61 -0
- package/template/src/services/api.ts +5 -7
- package/template/src/services/common.ts +23 -0
- package/template/src/styles.css +460 -0
- package/template/tsconfig.json +2 -1
- package/template/types.ts +44 -3
- package/template/vite.config.ts +24 -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
|
@@ -4,23 +4,28 @@ import { readFile } from "fs/promises";
|
|
|
4
4
|
import cors from "cors";
|
|
5
5
|
import express from "express";
|
|
6
6
|
|
|
7
|
-
// easy-db-node provides a simple local database storing data in JSON files.
|
|
8
|
-
// It's ideal for small to medium projects where setting up a full SQL/NoSQL DB is overkill.
|
|
9
7
|
import easyDBNode from "easy-db-node";
|
|
8
|
+
import { setAPIBackend } from "typed-client-server-api";
|
|
10
9
|
|
|
11
|
-
//
|
|
12
|
-
// ensuring SEO friendliness and faster initial page loads.
|
|
10
|
+
// --- SSR START ---
|
|
13
11
|
import { renderToHTML } from "ssr-hook/server";
|
|
12
|
+
// --- SSR END ---
|
|
14
13
|
|
|
15
|
-
//
|
|
16
|
-
// and this Express backend, preventing mismatch errors and providing autocomplete.
|
|
17
|
-
import { setAPIBackend } from "typed-client-server-api";
|
|
18
|
-
|
|
19
|
-
// easy-analytics handles privacy-friendly local analytics collection.
|
|
14
|
+
// --- ANALYTICS START ---
|
|
20
15
|
import { postEasyAnalytics, getEasyAnalytics } from "easy-analytics/server";
|
|
16
|
+
// --- ANALYTICS END ---
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
// --- AUTH START ---
|
|
19
|
+
import { EasyLoginServer } from "easy-user-auth/server";
|
|
20
|
+
import { sendMailForgottenPassword } from "./mail";
|
|
21
|
+
import { UserProfile } from "../types";
|
|
22
|
+
// --- AUTH END ---
|
|
23
23
|
|
|
24
|
+
// --- OGIMAGE START ---
|
|
25
|
+
import puppeteer from "puppeteer";
|
|
26
|
+
// --- OGIMAGE END ---
|
|
27
|
+
|
|
28
|
+
import App from "../src/App";
|
|
24
29
|
import { API, Database } from "../types";
|
|
25
30
|
|
|
26
31
|
const PORT = process.env.SERVER_PORT || 1111;
|
|
@@ -29,7 +34,7 @@ const LOCALHOST = `http://localhost:${PORT}`;
|
|
|
29
34
|
// The ORIGIN is used for SEO and by ssr-hook to know where the requests are coming from.
|
|
30
35
|
const ORIGIN = process.env.NODE_ENV === "development"
|
|
31
36
|
? LOCALHOST
|
|
32
|
-
:
|
|
37
|
+
: process.env.SERVER_URL || LOCALHOST;
|
|
33
38
|
|
|
34
39
|
const INDEX_HTML_PATH = resolve(__dirname, "..", "dist", "index.html");
|
|
35
40
|
|
|
@@ -45,23 +50,48 @@ const app = express();
|
|
|
45
50
|
|
|
46
51
|
if (process.env.NODE_ENV === "development") {
|
|
47
52
|
console.log("Starting development server...");
|
|
48
|
-
app.use(cors(
|
|
53
|
+
app.use(cors({
|
|
54
|
+
origin: ["http://localhost:5173", "http://localhost:1111"],
|
|
55
|
+
credentials: true
|
|
56
|
+
}));
|
|
57
|
+
} else {
|
|
58
|
+
app.use(cors({
|
|
59
|
+
origin: ORIGIN,
|
|
60
|
+
credentials: true
|
|
61
|
+
}));
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
//
|
|
52
|
-
app.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
// Parse incoming JSON payloads.
|
|
65
|
+
app.use(express.json({ limit: "1MB" }));
|
|
66
|
+
|
|
67
|
+
// --- AUTH START ---
|
|
68
|
+
const authServer = new EasyLoginServer<UserProfile>({
|
|
69
|
+
jwtSecret: process.env.JWT_SECRET || "fallback-secret-key-change-me",
|
|
70
|
+
secureCookies: process.env.NODE_ENV === "production",
|
|
71
|
+
db: {
|
|
72
|
+
insertUser: async (user) => insert("user", user),
|
|
73
|
+
getUserById: async (id) => select("user", id),
|
|
74
|
+
getUserByMail: async (mail) => {
|
|
75
|
+
const users = await selectArray("user");
|
|
76
|
+
return users.find(u => u.mail.toLowerCase() === mail.toLowerCase()) || null;
|
|
77
|
+
},
|
|
78
|
+
getUserByRecoveryToken: async (token) => {
|
|
79
|
+
const users = await selectArray("user");
|
|
80
|
+
return users.find(u => u.passwordRecovery?.token === token) || null;
|
|
81
|
+
},
|
|
82
|
+
updateUser: async (id, user) => {
|
|
83
|
+
await update("user", id, user);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
mailSender: async (lang, mailTo, token) => {
|
|
87
|
+
await sendMailForgottenPassword(lang, mailTo, token, ORIGIN);
|
|
88
|
+
},
|
|
63
89
|
});
|
|
64
90
|
|
|
91
|
+
// Register all user authentication routes (/api/login, /api/register, /api/user, etc.)
|
|
92
|
+
authServer.registerExpressRoutes(app);
|
|
93
|
+
// --- AUTH END ---
|
|
94
|
+
|
|
65
95
|
// Serve static files generated by the bundler (JS, CSS)
|
|
66
96
|
app.use(express.static("dist"));
|
|
67
97
|
// Serve any public assets (like images, fonts)
|
|
@@ -69,41 +99,95 @@ app.use(express.static("public"));
|
|
|
69
99
|
// Serve the database files (e.g. uploaded images or PDF files from easy-db-node)
|
|
70
100
|
app.use("/files", express.static("easy-db-files"));
|
|
71
101
|
|
|
102
|
+
// ------------------------------ og:image -------------------------------------
|
|
103
|
+
// --- OGIMAGE START ---
|
|
104
|
+
app.get("/og-image.png", async (req, res) => {
|
|
105
|
+
const path = req.query.path as string;
|
|
106
|
+
if (!path) {
|
|
107
|
+
return res.sendFile(resolve(__dirname, "..", "public", "images", "icon.png"));
|
|
108
|
+
}
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
res.send(`User-agent: *\nAllow: /\n\nSitemap: ${ORIGIN}/sitemap.xml`);
|
|
78
|
-
});
|
|
110
|
+
const image = await select("og-image", path);
|
|
111
|
+
if (image) {
|
|
112
|
+
return res.redirect(302, image.file.url);
|
|
113
|
+
}
|
|
79
114
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
try {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const browser = await puppeteer.launch({
|
|
118
|
+
headless: true,
|
|
119
|
+
args: [
|
|
120
|
+
'--no-sandbox',
|
|
121
|
+
'--disable-setuid-sandbox',
|
|
122
|
+
'--disable-gpu',
|
|
123
|
+
'--disable-dev-shm-usage',
|
|
124
|
+
'--no-first-run',
|
|
125
|
+
'--no-zygote',
|
|
126
|
+
'--single-process',
|
|
127
|
+
]
|
|
128
|
+
});
|
|
90
129
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
130
|
+
const page = await browser.newPage();
|
|
131
|
+
// --- SSR START ---
|
|
132
|
+
await page.setJavaScriptEnabled(false);
|
|
133
|
+
// --- SSR END ---
|
|
134
|
+
// --- NO_SSR START ---
|
|
135
|
+
await page.setJavaScriptEnabled(true);
|
|
136
|
+
// --- NO_SSR END ---
|
|
137
|
+
await page.setViewport({ width: 600, height: 500 });
|
|
138
|
+
|
|
139
|
+
// Render target page
|
|
140
|
+
await page.goto(`${LOCALHOST}${path}`, { waitUntil: 'networkidle0' });
|
|
141
|
+
const base64 = await page.screenshot({ type: "png", encoding: "base64" });
|
|
142
|
+
await browser.close();
|
|
100
143
|
|
|
144
|
+
await update("og-image", path, {
|
|
145
|
+
dateCreated: new Date().toISOString(),
|
|
146
|
+
file: {
|
|
147
|
+
type: "EASY_DB_FILE",
|
|
148
|
+
url: `data:image/png;base64,${base64}`,
|
|
149
|
+
},
|
|
150
|
+
} as any);
|
|
101
151
|
|
|
102
|
-
|
|
103
|
-
|
|
152
|
+
console.log(`Render og:image for ${path} took ${Date.now() - now} ms.`);
|
|
153
|
+
|
|
154
|
+
const img = await select("og-image", path);
|
|
155
|
+
return res.redirect(302, img!.file.url);
|
|
156
|
+
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.error("og:image rendering error:", e);
|
|
159
|
+
return res.sendFile(resolve(__dirname, "..", "public", "images", "icon.png"));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
// --- OGIMAGE END ---
|
|
163
|
+
|
|
164
|
+
// ---------------------------- SSR for index ----------------------------------
|
|
165
|
+
// --- SSR START ---
|
|
166
|
+
app.get(["/", "/index.html"], async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
169
|
+
const html = await renderToHTML(ORIGIN, req.url, indexHtml, <App />);
|
|
170
|
+
res.set("Content-Type", "text/html");
|
|
171
|
+
res.send(html);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error("SSR error:", e);
|
|
174
|
+
res.status(500).json({ message: "Internal server error" });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
// --- SSR END ---
|
|
178
|
+
// --- NO_SSR START ---
|
|
179
|
+
app.get(["/", "/index.html"], async (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
res.sendFile(INDEX_HTML_PATH);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error("Error serving index.html:", e);
|
|
184
|
+
res.status(500).json({ message: "Internal server error" });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
// --- NO_SSR END ---
|
|
104
188
|
|
|
105
189
|
// --------------------------- Easy analytics ----------------------------------
|
|
106
|
-
//
|
|
190
|
+
// --- ANALYTICS START ---
|
|
107
191
|
app.post("/api/easy-analytics", async (req, res) => {
|
|
108
192
|
const userAgent = req.headers['user-agent'] || "";
|
|
109
193
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || "";
|
|
@@ -116,10 +200,9 @@ app.post("/api/easy-analytics", async (req, res) => {
|
|
|
116
200
|
res.status(400).json({ message: e.message || "Bad request" });
|
|
117
201
|
}
|
|
118
202
|
});
|
|
203
|
+
// --- ANALYTICS END ---
|
|
119
204
|
|
|
120
205
|
// --------------------------------- API ---------------------------------------
|
|
121
|
-
// Set up our type-safe API backend. All functions defined here map directly to the
|
|
122
|
-
// API type defined in types.d.ts, and can be called from the frontend using by api., useApi., or useSSRApi.
|
|
123
206
|
setAPIBackend<API>(app, {
|
|
124
207
|
async getItems() {
|
|
125
208
|
return await selectArray("item");
|
|
@@ -127,7 +210,7 @@ setAPIBackend<API>(app, {
|
|
|
127
210
|
async getItem({ id }) {
|
|
128
211
|
const item = await select("item", id);
|
|
129
212
|
if (!item) throw new Error("Item not found");
|
|
130
|
-
return item;
|
|
213
|
+
return item;
|
|
131
214
|
},
|
|
132
215
|
async addItem({ name, value }) {
|
|
133
216
|
const id = await insert("item", { name, value });
|
|
@@ -137,19 +220,119 @@ setAPIBackend<API>(app, {
|
|
|
137
220
|
await remove("item", id);
|
|
138
221
|
return true;
|
|
139
222
|
},
|
|
140
|
-
|
|
223
|
+
|
|
224
|
+
// --- ADMIN_NO_AUTH START ---
|
|
141
225
|
async getEasyAnalyticsData({ password, month }) {
|
|
142
226
|
const validPassword = process.env.ADMIN_PASSWORD;
|
|
143
227
|
if (!validPassword || password !== validPassword) {
|
|
144
228
|
throw new Error("Invalid password or server error");
|
|
145
229
|
}
|
|
230
|
+
// --- ANALYTICS START ---
|
|
146
231
|
return await getEasyAnalytics(month);
|
|
147
|
-
|
|
148
|
-
|
|
232
|
+
// --- ANALYTICS END ---
|
|
233
|
+
// --- NO_ANALYTICS START ---
|
|
234
|
+
return [];
|
|
235
|
+
// --- NO_ANALYTICS END ---
|
|
236
|
+
},
|
|
237
|
+
// --- ADMIN_NO_AUTH END ---
|
|
238
|
+
|
|
239
|
+
// --- ADMIN_WITH_AUTH START ---
|
|
240
|
+
async getEasyAnalyticsData({ month }, req, res) {
|
|
241
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
242
|
+
const user = await select("user", session.userId);
|
|
243
|
+
if (!user || user.role !== "admin") {
|
|
244
|
+
throw new Error("Forbidden");
|
|
245
|
+
}
|
|
246
|
+
// --- ANALYTICS START ---
|
|
247
|
+
return await getEasyAnalytics(month);
|
|
248
|
+
// --- ANALYTICS END ---
|
|
249
|
+
// --- NO_ANALYTICS START ---
|
|
250
|
+
return [];
|
|
251
|
+
// --- NO_ANALYTICS END ---
|
|
252
|
+
},
|
|
253
|
+
// --- ADMIN_WITH_AUTH END ---
|
|
254
|
+
|
|
255
|
+
// --- ADMIN_ERRORS_NO_AUTH START ---
|
|
256
|
+
async getEasyAnalyticsErrors({ password, month }) {
|
|
257
|
+
const validPassword = process.env.ADMIN_PASSWORD;
|
|
258
|
+
if (!validPassword || password !== validPassword) {
|
|
259
|
+
throw new Error("Invalid password or server error");
|
|
260
|
+
}
|
|
261
|
+
return await selectArray(`easyAnalyticsError-${month}`);
|
|
262
|
+
},
|
|
263
|
+
// --- ADMIN_ERRORS_NO_AUTH END ---
|
|
264
|
+
|
|
265
|
+
// --- ADMIN_ERRORS_WITH_AUTH START ---
|
|
266
|
+
async getEasyAnalyticsErrors({ month }, req, res) {
|
|
267
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
268
|
+
const user = await select("user", session.userId);
|
|
269
|
+
if (!user || user.role !== "admin") {
|
|
270
|
+
throw new Error("Forbidden");
|
|
271
|
+
}
|
|
272
|
+
return await selectArray(`easyAnalyticsError-${month}`);
|
|
273
|
+
},
|
|
274
|
+
// --- ADMIN_ERRORS_WITH_AUTH END ---
|
|
275
|
+
|
|
276
|
+
// --- ADMIN_USERS START ---
|
|
277
|
+
async getUsers(params, req, res) {
|
|
278
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
279
|
+
const user = await select("user", session.userId);
|
|
280
|
+
if (!user || user.role !== "admin") {
|
|
281
|
+
throw new Error("Forbidden");
|
|
282
|
+
}
|
|
283
|
+
const allUsers = await selectArray("user");
|
|
284
|
+
return allUsers.map(u => ({
|
|
285
|
+
_id: u._id,
|
|
286
|
+
email: u.mail,
|
|
287
|
+
name: u.name || u.profile?.name || "",
|
|
288
|
+
role: u.profile?.role || u.role || "user"
|
|
289
|
+
}));
|
|
290
|
+
},
|
|
291
|
+
async updateUserRole({ userId, role }, req, res) {
|
|
292
|
+
const session = await authServer.checkLogin(req!, res!);
|
|
293
|
+
const user = await select("user", session.userId);
|
|
294
|
+
if (!user || user.role !== "admin") {
|
|
295
|
+
throw new Error("Forbidden");
|
|
296
|
+
}
|
|
297
|
+
const targetUser = await select("user", userId);
|
|
298
|
+
if (!targetUser) {
|
|
299
|
+
throw new Error("User not found");
|
|
300
|
+
}
|
|
301
|
+
targetUser.role = role;
|
|
302
|
+
targetUser.profile = targetUser.profile || { name: targetUser.name || "", role: "user" };
|
|
303
|
+
targetUser.profile.role = role;
|
|
304
|
+
await update("user", userId, targetUser);
|
|
305
|
+
return true;
|
|
306
|
+
},
|
|
307
|
+
// --- ADMIN_USERS END ---
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// --------------------------------- SEO ---------------------------------------
|
|
311
|
+
app.get("/robots.txt", (_, res) => {
|
|
312
|
+
res.type("text/plain");
|
|
313
|
+
res.send(`User-agent: *\nAllow: /\n\nSitemap: ${ORIGIN}/sitemap.xml`);
|
|
149
314
|
});
|
|
150
315
|
|
|
316
|
+
app.get("/sitemap.xml", async (_, res) => {
|
|
317
|
+
const sitemap = [{
|
|
318
|
+
url: ORIGIN + "/",
|
|
319
|
+
lastModified: new Date().toISOString().slice(0, 10),
|
|
320
|
+
changeFrequency: "weekly",
|
|
321
|
+
priority: 1,
|
|
322
|
+
}];
|
|
323
|
+
|
|
324
|
+
res.set("Content-Type", "text/xml");
|
|
325
|
+
res.send(`<?xml version="1.0" encoding="UTF-8" ?>
|
|
326
|
+
<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>
|
|
327
|
+
<loc>${s.url}</loc>
|
|
328
|
+
<lastmod>${s.lastModified}</lastmod>
|
|
329
|
+
${s.changeFrequency ? `<changefreq>${s.changeFrequency}</changefreq>` : ""}
|
|
330
|
+
<priority>${s.priority}</priority>
|
|
331
|
+
</url>`).join("\n")}</urlset>`);
|
|
332
|
+
});
|
|
151
333
|
|
|
152
334
|
// --------------------------------- SSR ---------------------------------------
|
|
335
|
+
// --- SSR START ---
|
|
153
336
|
app.get(/.*/, async (req, res) => {
|
|
154
337
|
try {
|
|
155
338
|
const indexHtml = await readFile(INDEX_HTML_PATH, "utf-8");
|
|
@@ -158,11 +341,15 @@ app.get(/.*/, async (req, res) => {
|
|
|
158
341
|
res.send(html);
|
|
159
342
|
} catch (e) {
|
|
160
343
|
console.error("SSR error:", e);
|
|
161
|
-
res.status(500);
|
|
162
|
-
res.json({ message: "Internal server error" });
|
|
344
|
+
res.status(500).json({ message: "Internal server error" });
|
|
163
345
|
}
|
|
164
346
|
});
|
|
165
|
-
|
|
347
|
+
// --- SSR END ---
|
|
348
|
+
// --- NO_SSR START ---
|
|
349
|
+
app.get(/.*/, async (req, res) => {
|
|
350
|
+
res.sendFile(INDEX_HTML_PATH);
|
|
351
|
+
});
|
|
352
|
+
// --- NO_SSR END ---
|
|
166
353
|
|
|
167
354
|
// Start listening for incoming requests.
|
|
168
355
|
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,13 +1,18 @@
|
|
|
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
18
|
renderPage={({ path }) => {
|
|
@@ -20,11 +25,15 @@ export default function AppRouter() {
|
|
|
20
25
|
// --- ADMIN START ---
|
|
21
26
|
if (path[0] === 'admin') return (
|
|
22
27
|
<Suspense fallback={<div style={{ padding: 40, textAlign: 'center' }}>Loading admin...</div>}>
|
|
23
|
-
<Admin />
|
|
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]} />;
|
package/template/src/index.tsx
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { createRoot
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
// --- SSR START ---
|
|
4
|
+
import { hydrateRoot } from "react-dom/client";
|
|
5
|
+
// --- SSR END ---
|
|
3
6
|
|
|
4
7
|
import { handleError } from "./services/api";
|
|
5
8
|
|
|
6
9
|
import App from "./App";
|
|
10
|
+
import "./styles.css";
|
|
11
|
+
|
|
12
|
+
// --- VIEWPORT START ---
|
|
13
|
+
// Fix iOS safari issues with viewport
|
|
14
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
15
|
+
const updateViewport = () => {
|
|
16
|
+
const metaViewport = document.querySelector('meta[name="viewport"]');
|
|
17
|
+
if (metaViewport) {
|
|
18
|
+
if (window.screen.width <= 600) {
|
|
19
|
+
metaViewport.setAttribute('content', 'width=600, user-scalable=no');
|
|
20
|
+
} else {
|
|
21
|
+
metaViewport.setAttribute('content', 'width=device-width');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
updateViewport();
|
|
26
|
+
window.addEventListener('resize', updateViewport);
|
|
27
|
+
}
|
|
28
|
+
// --- VIEWPORT END ---
|
|
7
29
|
|
|
8
30
|
const rootElement = document.getElementById("root") as HTMLElement;
|
|
9
31
|
|
|
32
|
+
// --- SSR START ---
|
|
10
33
|
if (process.env.NODE_ENV === "development") {
|
|
11
34
|
// We use createRoot because we often don't want to deal with full SSR hydration quirks
|
|
12
35
|
// during local frontend development (like Hot Module Replacement causing mismatches).
|
|
@@ -21,3 +44,7 @@ if (process.env.NODE_ENV === "development") {
|
|
|
21
44
|
onRecoverableError: handleError,
|
|
22
45
|
});
|
|
23
46
|
}
|
|
47
|
+
// --- SSR END ---
|
|
48
|
+
// --- NO_SSR START ---
|
|
49
|
+
createRoot(rootElement).render(<App />);
|
|
50
|
+
// --- NO_SSR END ---
|