1import nodemailer from "nodemailer";
2
3function escapeHtml(str) {
4 return str
5 .replace(/&/g, "&")
6 .replace(/</g, "<")
7 .replace(/>/g, ">")
8 .replace(/"/g, """)
9 .replace(/'/g, "'");
10}
11
12function createTransporter() {
13 return nodemailer.createTransport({
14 service: "gmail",
15 auth: {
16 user: process.env.EMAIL_USER,
17 pass: process.env.EMAIL_PASS,
18 },
19 });
20}
21
22export async function sendResetEmail(to, link) {
23 const transporter = createTransporter();
24 await transporter.sendMail({
25 from: `"TreeOS" <${process.env.EMAIL_USER}>`,
26 to,
27 subject: "Password Reset",
28 html: `
29 <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
30 <div style="text-align: center; margin-bottom: 32px;">
31 <h1 style="font-size: 24px; color: #1a1a1a; margin: 8px 0 0;">Tree</h1>
32 </div>
33 <p style="font-size: 16px; color: #333; line-height: 1.6;">We received a request to reset your password.</p>
34 <p style="font-size: 16px; color: #333; line-height: 1.6;">Click the button below to choose a new password:</p>
35 <div style="text-align: center; margin: 32px 0;">
36 <a href="${link}" style="display: inline-block; background-color: #736fe6; color: white; text-decoration: none; padding: 14px 32px; border-radius: 980px; font-size: 16px; font-weight: 600;">Reset My Password</a>
37 </div>
38 <p style="font-size: 13px; color: #888; line-height: 1.5;">This link expires in 15 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
39 <hr style="border: none; border-top: 1px solid #eee; margin: 32px 0 16px;" />
40 <p style="font-size: 12px; color: #aaa; line-height: 1.5;">If the button doesn't work, copy and paste this link into your browser:<br /><a href="${link}" style="color: #736fe6; word-break: break-all;">${link}</a></p>
41 </div>
42 `,
43 });
44}
45
46export async function sendVerificationEmail(to, link, username) {
47 const transporter = createTransporter();
48 await transporter.sendMail({
49 from: `"TreeOS" <${process.env.EMAIL_USER}>`,
50 to,
51 subject: "Complete Your Registration",
52 html: `
53 <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
54 <div style="text-align: center; margin-bottom: 32px;">
55 <h1 style="font-size: 24px; color: #1a1a1a; margin: 8px 0 0;">Tree</h1>
56 </div>
57 <p style="font-size: 16px; color: #333; line-height: 1.6;">Hey ${escapeHtml(username)}, thanks for signing up!</p>
58 <p style="font-size: 16px; color: #333; line-height: 1.6;">Click the button below to verify your email and activate your account:</p>
59 <div style="text-align: center; margin: 32px 0;">
60 <a href="${link}" style="display: inline-block; background-color: #736fe6; color: white; text-decoration: none; padding: 14px 32px; border-radius: 980px; font-size: 16px; font-weight: 600;">Verify My Email</a>
61 </div>
62 <p style="font-size: 13px; color: #888; line-height: 1.5;">This link expires in 12 hours. If you didn't create this account, you can safely ignore this email.</p>
63 <hr style="border: none; border-top: 1px solid #eee; margin: 32px 0 16px;" />
64 <p style="font-size: 12px; color: #aaa; line-height: 1.5;">If the button doesn't work, copy and paste this link into your browser:<br /><a href="${link}" style="color: #736fe6; word-break: break-all;">${link}</a></p>
65 </div>
66 `,
67 });
68}
69
70export { escapeHtml };
71
1import express from "express";
2import log from "../../seed/log.js";
3import Node from "../../seed/models/node.js";
4import User from "../../seed/models/user.js";
5import { sendError, ERR } from "../../seed/protocol.js";
6import urlAuth from "../html-rendering/urlAuth.js";
7import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js";
8import { getExtension } from "../loader.js";
9import { isHtmlEnabled } from "../html-rendering/config.js";
10import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
11import {
12 renderResetPasswordExpired,
13 renderResetPasswordForm,
14 renderResetPasswordMismatch,
15 renderResetPasswordInvalid,
16 renderResetPasswordSuccess,
17} from "./pages/passwordReset.js";
18
19export default function buildEmailHtmlRoutes() {
20 const router = express.Router();
21
22 router.get("/user/reset-password/:token", async (req, res) => {
23 if (!isHtmlEnabled()) {
24 return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "HTML rendering disabled");
25 }
26 try {
27 const user = await User.findOne({
28 "metadata.email.resetToken": req.params.token,
29 "metadata.email.resetExpiry": { $gt: Date.now() },
30 });
31 if (!user) return res.send(renderResetPasswordExpired());
32 return res.send(renderResetPasswordForm({ token: req.params.token }));
33 } catch (err) {
34 log.error("HTML", "Reset password form error:", err.message);
35 sendError(res, 500, ERR.INTERNAL, err.message);
36 }
37 });
38
39 router.post("/user/reset-password/:token", async (req, res) => {
40 if (!isHtmlEnabled()) {
41 return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "HTML rendering disabled");
42 }
43 try {
44 const { password, confirm } = req.body;
45 if (password !== confirm) {
46 return res.send(renderResetPasswordMismatch({ token: req.params.token }));
47 }
48
49 const user = await User.findOne({
50 "metadata.email.resetToken": req.params.token,
51 "metadata.email.resetExpiry": { $gt: Date.now() },
52 });
53 if (!user) return res.send(renderResetPasswordInvalid());
54
55 user.password = password;
56 const emailMeta = getUserMeta(user, "email");
57 delete emailMeta.resetToken;
58 delete emailMeta.resetExpiry;
59 emailMeta.tokensInvalidBefore = new Date();
60 setUserMeta(user, "email", emailMeta);
61 await user.save();
62
63 return res.send(renderResetPasswordSuccess());
64 } catch (err) {
65 log.error("HTML", "Reset password error:", err.message);
66 sendError(res, 500, ERR.INTERNAL, err.message);
67 }
68 });
69
70 return router;
71}
72
1import crypto from "crypto";
2import { sendOk, sendError, ERR } from "../../seed/protocol.js";
3import TempUser from "./model.js";
4import { sendVerificationEmail } from "./core.js";
5import { getLandUrl } from "../../canopy/identity.js";
6import { getLandConfigValue } from "../../seed/landConfig.js";
7import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
8
9function escapeRegex(str) {
10 return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11}
12
13const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
14
15export async function init(core) {
16 const { default: router, setModels } = await import("./routes.js");
17 setModels(core.models);
18 const User = core.models.User;
19
20 core.hooks.register("beforeRegister", async (data) => {
21 const { username, password, req, res } = data;
22 const email = req.body?.email;
23
24 const requireEmail = getLandConfigValue("REQUIRE_EMAIL") !== "false";
25
26 if (requireEmail && !email) {
27 sendError(res, 400, ERR.INVALID_INPUT, "Email is required for registration");
28 data.handled = true;
29 return;
30 }
31
32 if (!email) return;
33
34 if (!EMAIL_REGEX.test(email) || email.length > 320) {
35 sendError(res, 400, ERR.INVALID_INPUT, "Please enter a valid email address");
36 data.handled = true;
37 return;
38 }
39
40 const normalizedEmail = email.trim().toLowerCase();
41
42 const existingEmail = await User.findOne({ "metadata.email.address": normalizedEmail });
43 if (existingEmail) {
44 sendError(res, 400, ERR.INVALID_INPUT, "Email already registered");
45 data.handled = true;
46 return;
47 }
48
49 await TempUser.deleteMany({
50 $or: [
51 { email: normalizedEmail },
52 { username: { $regex: `^${escapeRegex(username)}$`, $options: "i" } },
53 ],
54 });
55
56 const verificationToken = crypto.randomBytes(32).toString("hex");
57
58 await TempUser.create({
59 username,
60 email: normalizedEmail,
61 password,
62 verificationToken,
63 expiresAt: Date.now() + 1000 * 60 * 60 * 12, // 12 hours
64 });
65
66 const verifyUrl = `${getLandUrl()}/api/v1/user/verify/${verificationToken}`;
67 await sendVerificationEmail(normalizedEmail, verifyUrl, username);
68
69 sendOk(res, {
70 pendingVerification: true,
71 message: "Check your email to complete registration",
72 }, 201);
73 data.handled = true;
74 }, "email");
75
76 core.hooks.register("afterRegister", async ({ user, email }) => {
77 if (!email) return;
78 const freshUser = await User.findById(user._id);
79 if (!freshUser) return;
80
81 // Don't overwrite if email metadata already exists (verify route sets verified: true first)
82 const existing = getUserMeta(freshUser, "email");
83 if (existing?.address) return;
84
85 const normalizedEmail = email.trim().toLowerCase();
86 const { batchSetUserMeta } = await import("../../seed/tree/userMetadata.js");
87 await batchSetUserMeta(String(freshUser._id), "email", { address: normalizedEmail, verified: false });
88 }, "email");
89
90 try {
91 const { getExtension } = await import("../loader.js");
92 const htmlExt = getExtension("html-rendering");
93 if (htmlExt) {
94 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
95 htmlExt.router.use("/", buildHtmlRoutes());
96 }
97 } catch {}
98
99 return { router };
100}
101
1export default {
2 name: "email",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Without this extension, registration is instant: pick a username and password, you're " +
7 "in. That simplicity is intentional for development and private lands. But public-facing " +
8 "lands need email verification to prevent throwaway accounts and provide a recovery " +
9 "path when passwords are forgotten. This extension adds both. " +
10 "\n\n" +
11 "Registration flow changes completely when email is installed. The beforeRegister hook " +
12 "intercepts the normal registration process. Instead of creating a user immediately, " +
13 "it creates a TempUser record with the username, bcrypt-hashed password, normalized " +
14 "email address, and a cryptographic verification token. A styled HTML email is sent " +
15 "with a verification link. The link expires after 12 hours. Clicking it creates the " +
16 "real user account, stores the verified email in user metadata, fires afterRegister " +
17 "hooks, generates a JWT, sets the auth cookie, and redirects to setup. The TempUser " +
18 "record is deleted. Duplicate registrations with the same email or username " +
19 "automatically clean up previous pending TempUser records. " +
20 "\n\n" +
21 "Password reset uses a separate token flow. The forgot-password endpoint accepts an " +
22 "email, looks up the user by the email stored in their metadata, generates a 256-bit " +
23 "reset token stored in user metadata with a 15-minute expiry, and sends a styled " +
24 "reset email. The reset endpoint validates the token, updates the password, clears " +
25 "the reset token, and invalidates all existing JWT sessions by writing a " +
26 "tokensInvalidBefore timestamp to the user's auth metadata. This forces re-login on " +
27 "all devices after a password change. " +
28 "\n\n" +
29 "Rate limiting protects the email endpoints. Forgot-password is capped at three " +
30 "requests per hour per IP. The response is deliberately identical whether the email " +
31 "exists or not, preventing enumeration. Email sending uses nodemailer with Gmail " +
32 "transport configured via EMAIL_USER and EMAIL_PASS environment variables. If " +
33 "html-rendering is installed, the forgot-password page renders as a styled HTML form.",
34
35 npm: ["nodemailer@^7.0.11"],
36
37 needs: {
38 services: ["auth"],
39 models: ["User"],
40 },
41
42 optional: {
43 extensions: ["html-rendering"],
44 },
45
46 provides: {
47 models: {
48 TempUser: "./model.js",
49 },
50 routes: "./routes.js",
51 tools: false,
52 jobs: false,
53 env: [
54 { key: "EMAIL_USER", required: true, description: "Email account for sending (e.g. Gmail address)" },
55 { key: "EMAIL_PASS", required: true, secret: true, description: "Email account password or app password" },
56 ],
57 cli: [],
58 hooks: {
59 fires: [],
60 listens: ["beforeRegister", "afterRegister"],
61 },
62 },
63};
64
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const TempUserSchema = new mongoose.Schema({
5 _id: { type: String, required: true, default: uuidv4 },
6
7 username: {
8 type: String,
9 required: true,
10 },
11
12 email: {
13 type: String,
14 required: true,
15 },
16
17 // Stored in plaintext. Protected by verification token + 12 hour TTL + MongoDB TTL auto-delete.
18 // The real bcrypt hash happens when User.create() runs the User model's pre-save hook.
19 // Do NOT hash here. TempUser hashing + User hashing = double-hash = user can never log in.
20 password: {
21 type: String,
22 required: true,
23 },
24
25 verificationToken: {
26 type: String,
27 required: true,
28 unique: true,
29 },
30
31 expiresAt: {
32 type: Date,
33 required: true,
34 index: { expireAfterSeconds: 0 },
35 },
36});
37
38export default mongoose.model("TempUser", TempUserSchema);
39
1{
2 "name": "treeos-ext-email",
3 "version": "1.0.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "treeos-ext-email",
9 "version": "1.0.0",
10 "dependencies": {
11 "nodemailer": "^7.0.11"
12 }
13 },
14 "node_modules/nodemailer": {
15 "version": "7.0.13",
16 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
17 "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
18 "license": "MIT-0",
19 "engines": {
20 "node": ">=6.0.0"
21 }
22 }
23 }
24}
25
1{
2 "type": "module",
3 "name": "treeos-ext-email",
4 "version": "1.0.0",
5 "private": true,
6 "dependencies": {
7 "nodemailer": "^7.0.11"
8 }
9}
1import { page } from "../../html-rendering/html/layout.js";
2import { esc } from "../../html-rendering/html/utils.js";
3import { baseStyles } from "../../html-rendering/html/baseStyles.js";
4
5// All password reset functions use bare: true because they have a completely different layout
6
7export function renderResetPasswordExpired() {
8 const css = `
9${baseStyles}
10
11body {
12 display: flex;
13 flex-direction: column;
14 align-items: center;
15 justify-content: center;
16}
17
18
19 @keyframes fadeInDown {
20 from { opacity: 0; transform: translateY(-30px); }
21 to { opacity: 1; transform: translateY(0); }
22 }
23
24 @keyframes slideUp {
25 from { opacity: 0; transform: translateY(30px); }
26 to { opacity: 1; transform: translateY(0); }
27 }
28
29 .brand-header {
30 position: relative; z-index: 1;
31 margin-bottom: 32px; text-align: center;
32 animation: fadeInDown 0.8s ease-out;
33 }
34
35 .brand-logo {
36 font-size: 80px; margin-bottom: 16px; display: inline-block;
37 filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
38 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
39 }
40
41 @keyframes grow {
42 0%, 100% { transform: scale(1); }
43 50% { transform: scale(1.06); }
44 }
45
46 .brand-title {
47 font-size: 56px; font-weight: 600; color: white;
48 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
49 letter-spacing: -1.5px; margin-bottom: 8px;
50 }
51
52 .container {
53 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
54 backdrop-filter: blur(22px) saturate(140%);
55 -webkit-backdrop-filter: blur(22px) saturate(140%);
56 padding: 48px;
57 border-radius: 16px;
58 width: 100%; max-width: 460px;
59 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
60 border: 1px solid rgba(255,255,255,0.28);
61 text-align: center;
62 position: relative; z-index: 1;
63 animation: slideUp 0.6s ease-out 0.2s both;
64 }
65
66 h2 {
67 font-size: 32px; font-weight: 600; color: white;
68 margin-bottom: 12px;
69 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
70 }
71
72 .subtitle {
73 font-size: 15px; color: rgba(255,255,255,0.85);
74 margin-bottom: 24px; line-height: 1.5;
75 }
76
77 .back-btn {
78 display: inline-block;
79 width: 100%;
80 padding: 14px;
81 margin-top: 16px;
82 border-radius: 980px;
83 border: 1px solid rgba(255,255,255,0.3);
84 background: rgba(255,255,255,0.25);
85 backdrop-filter: blur(10px);
86 color: white;
87 font-size: 16px; font-weight: 600;
88 cursor: pointer;
89 transition: all 0.3s;
90 text-decoration: none;
91 text-align: center;
92 font-family: inherit;
93 }
94
95 .back-btn:hover {
96 background: rgba(255,255,255,0.35);
97 transform: translateY(-2px);
98 box-shadow: 0 6px 20px rgba(0,0,0,0.18);
99 }
100
101 @media (max-width: 640px) {
102 .brand-logo { font-size: 64px; }
103 .brand-title { font-size: 42px; }
104 .container { padding: 32px 24px; }
105 h2 { font-size: 28px; }
106 }`;
107
108 const body = `
109 <div class="brand-header">
110 <a href="/" style="text-decoration: none;">
111 <div class="brand-logo">\uD83C\uDF33</div>
112 <h1 class="brand-title">TreeOS</h1>
113 </a>
114 </div>
115
116 <div class="container">
117 <h2>Link Expired</h2>
118 <p class="subtitle">This reset link is invalid or has expired. Please request a new password reset.</p>
119 <a href="/login" class="back-btn">\u2190 Back to Login</a>
120 </div>`;
121
122 return page({
123 title: "TreeOS - Link Expired",
124 css,
125 body,
126 bare: true,
127 });
128}
129
130export function renderResetPasswordForm({ token }) {
131 const css = `
132${baseStyles}
133
134body {
135 display: flex;
136 flex-direction: column;
137 align-items: center;
138 justify-content: center;
139}
140
141
142 @keyframes fadeInDown {
143 from { opacity: 0; transform: translateY(-30px); }
144 to { opacity: 1; transform: translateY(0); }
145 }
146
147 @keyframes slideUp {
148 from { opacity: 0; transform: translateY(30px); }
149 to { opacity: 1; transform: translateY(0); }
150 }
151
152 @keyframes spin { to { transform: rotate(360deg); } }
153
154 .brand-header {
155 position: relative; z-index: 1;
156 margin-bottom: 32px; text-align: center;
157 animation: fadeInDown 0.8s ease-out;
158 }
159
160 .brand-logo {
161 font-size: 80px; margin-bottom: 16px; display: inline-block;
162 filter: drop-shadow(0 8px 32px rgba(0,0,0,0.2));
163 animation: fadeInDown 0.5s ease-out both, grow 4.5s ease-in-out infinite;
164 }
165
166 @keyframes grow {
167 0%, 100% { transform: scale(1); }
168 50% { transform: scale(1.06); }
169 }
170
171 .brand-title {
172 font-size: 56px; font-weight: 600; color: white;
173 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
174 letter-spacing: -1.5px; margin-bottom: 8px;
175 }
176
177 .container {
178 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
179 backdrop-filter: blur(22px) saturate(140%);
180 -webkit-backdrop-filter: blur(22px) saturate(140%);
181 padding: 48px;
182 border-radius: 16px;
183 width: 100%; max-width: 460px;
184 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
185 border: 1px solid rgba(255,255,255,0.28);
186 text-align: center;
187 position: relative; z-index: 1;
188 animation: slideUp 0.6s ease-out 0.2s both;
189 }
190
191 h2 {
192 font-size: 32px; font-weight: 600; color: white;
193 margin-bottom: 8px;
194 text-shadow: 0 2px 8px rgba(0,0,0,0.2);
195 }
196
197 .subtitle {
198 font-size: 15px; color: rgba(255,255,255,0.85);
199 margin-bottom: 32px; line-height: 1.5;
200 }
201
202 .input-group {
203 margin-bottom: 16px;
204 text-align: left;
205 }
206
207 label {
208 display: block;
209 font-size: 14px; font-weight: 600; color: white;
210 margin-bottom: 8px;
211 text-shadow: 0 1px 3px rgba(0,0,0,0.2);
212 }
213
214 input {
215 width: 100%;
216 padding: 14px 18px;
217 border-radius: 12px;
218 border: 2px solid rgba(255,255,255,0.3);
219 font-size: 16px;
220 transition: all 0.3s cubic-bezier(0.4,0,0.2,1);
221 background: rgba(255,255,255,0.15);
222 backdrop-filter: blur(20px) saturate(150%);
223 -webkit-backdrop-filter: blur(20px) saturate(150%);
224 font-family: inherit;
225 color: white;
226 font-weight: 500;
227 box-shadow: 0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.25);
228 }
229
230 input:focus {
231 outline: none;
232 border-color: rgba(255,255,255,0.6);
233 background: rgba(255,255,255,0.25);
234 box-shadow: 0 0 0 4px rgba(255,255,255,0.15), 0 8px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.4);
235 transform: translateY(-2px);
236 }
237
238 input::placeholder {
239 color: rgba(255,255,255,0.5);
240 font-weight: 400;
241 }
242
243 input.error {
244 border-color: rgba(239,68,68,0.6);
245 background: rgba(239,68,68,0.1);
246 }
247
248 .password-hint {
249 font-size: 12px;
250 color: rgba(255,255,255,0.7);
251 margin-top: 6px;
252 text-align: left;
253 }
254
255 button {
256 width: 100%;
257 padding: 16px;
258 margin-top: 8px;
259 border-radius: 980px;
260 border: 1px solid rgba(255,255,255,0.3);
261 background: rgba(255,255,255,0.25);
262 backdrop-filter: blur(10px);
263 color: white;
264 font-size: 16px; font-weight: 600;
265 cursor: pointer;
266 transition: all 0.3s;
267 box-shadow: 0 4px 12px rgba(0,0,0,0.12);
268 font-family: inherit;
269 position: relative;
270 overflow: hidden;
271 }
272
273 button:hover {
274 background: rgba(255,255,255,0.35);
275 transform: translateY(-2px);
276 box-shadow: 0 6px 20px rgba(0,0,0,0.18);
277 }
278
279 button:active { transform: translateY(0); }
280
281 button.loading {
282 color: transparent;
283 pointer-events: none;
284 }
285
286 button.loading::after {
287 content: '';
288 position: absolute;
289 width: 20px; height: 20px;
290 top: 50%; left: 50%;
291 margin-left: -10px; margin-top: -10px;
292 border: 3px solid rgba(255,255,255,0.3);
293 border-radius: 50%;
294 border-top-color: white;
295 animation: spin 0.8s linear infinite;
296 }
297
298 .message {
299 margin-top: 16px;
300 padding: 12px 16px;
301 border-radius: 10px;
302 font-size: 14px; font-weight: 600;
303 text-align: left;
304 display: none;
305 }
306
307 .error-message {
308 color: white;
309 background: rgba(239,68,68,0.3);
310 backdrop-filter: blur(10px);
311 border: 1px solid rgba(239,68,68,0.4);
312 }
313
314 .success-message {
315 color: white;
316 background: rgba(16,185,129,0.3);
317 backdrop-filter: blur(10px);
318 border: 1px solid rgba(16,185,129,0.4);
319 }
320
321 .message.show { display: block; }
322
323 .back-btn {
324 display: inline-block;
325 width: 100%;
326 padding: 12px;
327 margin-top: 16px;
328 border-radius: 980px;
329 border: 1px solid rgba(255,255,255,0.3);
330 background: rgba(255,255,255,0.15);
331 color: white;
332 font-size: 15px; font-weight: 600;
333 cursor: pointer;
334 transition: all 0.3s;
335 text-decoration: none;
336 text-align: center;
337 }
338
339 .back-btn:hover {
340 background: rgba(255,255,255,0.25);
341 transform: translateY(-2px);
342 }
343
344 @media (max-width: 640px) {
345 .brand-logo { font-size: 64px; }
346 .brand-title { font-size: 42px; }
347 .container { padding: 32px 24px; }
348 h2 { font-size: 28px; }
349 input { font-size: 16px; }
350 }`;
351
352 const body = `
353 <div class="brand-header">
354 <a href="/" style="text-decoration: none;">
355 <div class="brand-logo">\uD83C\uDF33</div>
356 <h1 class="brand-title">TreeOS</h1>
357 </a>
358 </div>
359
360 <div class="container">
361 <h2>Reset Password</h2>
362 <p class="subtitle">Enter your new password below</p>
363
364 <form id="resetForm" data-token="${esc(token)}">
365 <div class="input-group">
366 <label for="password">New Password</label>
367 <input
368 type="password"
369 id="password"
370 placeholder="Enter new password"
371 required
372 autocomplete="new-password"
373 />
374 <div class="password-hint">Must be at least 8 characters</div>
375 </div>
376
377 <div class="input-group">
378 <label for="confirm">Confirm Password</label>
379 <input
380 type="password"
381 id="confirm"
382 placeholder="Confirm new password"
383 required
384 autocomplete="new-password"
385 />
386 </div>
387
388 <button type="submit" id="resetBtn">Reset Password</button>
389 </form>
390
391 <div id="errorMessage" class="message error-message"></div>
392 <div id="successMessage" class="message success-message">
393 \u2713 Password reset successful! Redirecting to login...
394 </div>
395
396 <a href="/login" class="back-btn">\u2190 Back to Login</a>
397 </div>`;
398
399 const js = `
400 const form = document.getElementById("resetForm");
401 const passwordInput = document.getElementById("password");
402 const confirmInput = document.getElementById("confirm");
403 const errorEl = document.getElementById("errorMessage");
404 const successEl = document.getElementById("successMessage");
405 const btn = document.getElementById("resetBtn");
406
407 confirmInput.addEventListener("input", () => {
408 if (confirmInput.value && passwordInput.value !== confirmInput.value) {
409 confirmInput.classList.add("error");
410 } else {
411 confirmInput.classList.remove("error");
412 }
413 });
414
415 form.addEventListener("submit", async (e) => {
416 e.preventDefault();
417
418 const password = passwordInput.value;
419 const confirm = confirmInput.value;
420
421 errorEl.classList.remove("show");
422 successEl.classList.remove("show");
423 passwordInput.classList.remove("error");
424 confirmInput.classList.remove("error");
425
426 if (password.length < 8) {
427 errorEl.textContent = "Password must be at least 8 characters.";
428 errorEl.classList.add("show");
429 passwordInput.classList.add("error");
430 passwordInput.focus();
431 return;
432 }
433
434 if (password !== confirm) {
435 errorEl.textContent = "Passwords do not match.";
436 errorEl.classList.add("show");
437 confirmInput.classList.add("error");
438 confirmInput.focus();
439 return;
440 }
441
442 btn.classList.add("loading");
443 btn.disabled = true;
444
445 try {
446 const res = await fetch("/api/v1/user/reset-password", {
447 method: "POST",
448 headers: { "Content-Type": "application/json" },
449 body: JSON.stringify({ token: form.dataset.token, password }),
450 });
451
452 const data = await res.json();
453
454 if (!res.ok || data.status === "error") {
455 errorEl.textContent = (data.error && data.error.message) || data.message || "Reset failed. Please try again.";
456 errorEl.classList.add("show");
457 btn.classList.remove("loading");
458 btn.disabled = false;
459 return;
460 }
461
462 successEl.classList.add("show");
463 form.style.display = "none";
464
465 setTimeout(() => {
466 window.location.href = "/login";
467 }, 2000);
468
469 } catch (err) {
470 errorEl.textContent = "An error occurred. Please try again.";
471 errorEl.classList.add("show");
472 btn.classList.remove("loading");
473 btn.disabled = false;
474 }
475 });`;
476
477 return page({
478 title: "TreeOS - Reset Password",
479 css,
480 body,
481 js,
482 bare: true,
483 });
484}
485
486export function renderResetPasswordMismatch({ token }) {
487 return (`
488 <html><body style="font-family:sans-serif; padding:20px;">
489 <h2>Passwords Do Not Match</h2>
490 <p><a href="/api/v1/user/reset-password/${encodeURIComponent(token)}">Try Again</a></p>
491 </body></html>
492 `);
493}
494
495export function renderResetPasswordInvalid() {
496 return (`
497 <html><body style="font-family:sans-serif; padding:20px;">
498 <h2>Reset Link Expired or Invalid</h2>
499 <p>Please request a new password reset.</p>
500 </body></html>
501 `);
502}
503
504export function renderResetPasswordSuccess() {
505 return (`
506 <html><body style="font-family:sans-serif; padding:20px;">
507 <h2>Password Reset Successfully</h2>
508 <p>You can now log in with your new password.</p>
509 </body></html>
510 `);
511}
512
1import log from "../../seed/log.js";
2import express from "express";
3import crypto from "crypto";
4import jwt from "jsonwebtoken";
5// User model wired from init via setModels
6let User = null;
7export function setModels(models) { User = models.User; }
8import TempUser from "./model.js";
9import { sendResetEmail } from "./core.js";
10import { getLandUrl } from "../../canopy/identity.js";
11import { getLandConfigValue } from "../../seed/landConfig.js";
12import { getExtension } from "../loader.js";
13import { sendOk, sendError, ERR } from "../../seed/protocol.js";
14import authenticate from "../../seed/middleware/authenticate.js";
15import rateLimit from "express-rate-limit";
16import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
17
18const JWT_SECRET = process.env.JWT_SECRET;
19
20function cookieDomain(req) {
21 const host = req.hostname || req.headers.host || "";
22 return host.replace(/:\d+$/, "");
23}
24
25function escapeRegex(str) {
26 return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
27}
28
29const emailLimiter = rateLimit({
30 windowMs: 60 * 60 * 1000,
31 max: 3,
32 handler: (req, res) => {
33 sendError(res, 429, ERR.RATE_LIMITED, "Too many email requests.");
34 },
35});
36
37const router = express.Router();
38
39router.post("/forgot-password", emailLimiter, async (req, res) => {
40 const { email } = req.body;
41 const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
42 if (!email || !EMAIL_REGEX.test(email)) {
43 return sendOk(res, { message: "Reset link sent if email exists" });
44 }
45
46 const user = await User.findOne({ "metadata.email.address": email.trim().toLowerCase() });
47 if (!user) {
48 return sendOk(res, { message: "Reset link sent if email exists" });
49 }
50
51 const token = crypto.randomBytes(32).toString("hex");
52
53 const emailMeta = getUserMeta(user, "email");
54 emailMeta.resetToken = token;
55 emailMeta.resetExpiry = Date.now() + 1000 * 60 * 15;
56 setUserMeta(user, "email", emailMeta);
57 await user.save();
58
59 const resetURL = `${getLandUrl()}/api/v1/user/reset-password/${token}`;
60 await sendResetEmail(emailMeta.address, resetURL);
61
62 sendOk(res, { message: "Reset link sent if email exists" });
63});
64
65router.post("/user/reset-password", async (req, res) => {
66 const { token, password } = req.body;
67 if (!password || typeof password !== "string" || password.length < 8) {
68 return sendError(res, 400, ERR.INVALID_INPUT, "Password must be at least 8 characters long");
69 }
70
71 const user = await User.findOne({
72 "metadata.email.resetToken": token,
73 "metadata.email.resetExpiry": { $gt: Date.now() },
74 });
75
76 if (!user) {
77 return sendError(res, 403, ERR.SESSION_EXPIRED, "Invalid or expired token");
78 }
79
80 user.password = password;
81
82 // Clear reset token
83 const emailMeta = getUserMeta(user, "email");
84 delete emailMeta.resetToken;
85 delete emailMeta.resetExpiry;
86 setUserMeta(user, "email", emailMeta);
87
88 // Invalidate all existing JWT tokens
89 const authMeta = getUserMeta(user, "auth");
90 authMeta.tokensInvalidBefore = new Date().toISOString();
91 setUserMeta(user, "auth", authMeta);
92
93 await user.save();
94
95 sendOk(res, { message: "Password has been reset successfully" });
96});
97
98router.get("/user/verify/:token", async (req, res) => {
99 try {
100 const { token } = req.params;
101
102 const tempUser = await TempUser.findOne({
103 verificationToken: token,
104 expiresAt: { $gt: Date.now() },
105 });
106
107 if (!tempUser) {
108 return sendError(res, 403, ERR.SESSION_EXPIRED, "Invalid or expired verification link");
109 }
110
111 const existingUser = await User.findOne({
112 username: { $regex: `^${escapeRegex(tempUser.username)}$`, $options: "i" },
113 });
114 if (existingUser) {
115 await tempUser.deleteOne();
116 return sendError(res, 400, ERR.INVALID_INPUT, "Username already taken");
117 }
118
119 const existingEmail = await User.findOne({ "metadata.email.address": tempUser.email });
120 if (existingEmail) {
121 await tempUser.deleteOne();
122 return sendError(res, 400, ERR.INVALID_INPUT, "Email already registered");
123 }
124
125 const user = await User.create({
126 username: tempUser.username,
127 password: tempUser.password,
128 // Tier set via user-tiers extension if installed
129 });
130
131 setUserMeta(user, "email", { address: tempUser.email, verified: true });
132 await user.save();
133
134 const { hooks } = await import("../../seed/hooks.js");
135 hooks.run("afterRegister", { user, email: tempUser.email }).catch(() => {});
136
137 const authToken = jwt.sign(
138 { userId: user._id, username: user.username },
139 JWT_SECRET,
140 { expiresIn: "365d" },
141 );
142
143 res.cookie("token", authToken, {
144 httpOnly: true,
145 secure: true,
146 sameSite: "Lax",
147 domain: cookieDomain(req),
148 maxAge: 604800000,
149 path: "/",
150 });
151
152 await tempUser.deleteOne();
153 return res.redirect(`${getLandUrl()}/setup`);
154 } catch (err) {
155 log.error("Email", "[email:verifyEmail]", err);
156 sendError(res, 500, ERR.INTERNAL, "Verification failed");
157 }
158});
159
160router.post("/user/change-password", authenticate, async (req, res) => {
161 try {
162 const { oldPassword, newPassword } = req.body;
163 if (!oldPassword || typeof oldPassword !== "string") {
164 return sendError(res, 400, ERR.INVALID_INPUT, "Current password is required");
165 }
166 if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
167 return sendError(res, 400, ERR.INVALID_INPUT, "New password must be at least 8 characters");
168 }
169
170 const user = await User.findById(req.userId).select("+password metadata");
171 if (!user) return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
172
173 const bcrypt = (await import("bcrypt")).default;
174 const valid = await bcrypt.compare(oldPassword, user.password);
175 if (!valid) {
176 return sendError(res, 403, ERR.FORBIDDEN, "Current password is incorrect");
177 }
178
179 // Set new password. User pre-save hook rehashes it.
180 user.password = newPassword;
181
182 // Invalidate all existing tokens
183 const authMeta = getUserMeta(user, "auth");
184 authMeta.tokensInvalidBefore = new Date().toISOString();
185 setUserMeta(user, "auth", authMeta);
186
187 await user.save();
188
189 // Issue new token so user stays logged in
190 const newToken = jwt.sign(
191 { userId: user._id, username: user.username },
192 JWT_SECRET,
193 { expiresIn: "365d" },
194 );
195
196 sendOk(res, { message: "Password changed successfully", token: newToken });
197 } catch (err) {
198 log.error("Email", "Change password error:", err.message);
199 sendError(res, 500, ERR.INTERNAL, "Failed to change password");
200 }
201});
202
203router.get("/forgot-password", (req, res) => {
204 const htmlExt = getExtension("html-rendering");
205 const render = htmlExt?.exports?.renderForgotPasswordPage;
206 if (!getExtension("html-rendering") || !render) {
207 return sendError(res, 404, ERR.EXTENSION_NOT_FOUND, "Not available");
208 }
209 render(req, res);
210});
211
212export default router;
213
Loading comments...