EXTENSION for TreeOS
email
Without this extension, registration is instant: pick a username and password, you're in. That simplicity is intentional for development and private lands. But public-facing lands need email verification to prevent throwaway accounts and provide a recovery path when passwords are forgotten. This extension adds both. Registration flow changes completely when email is installed. The beforeRegister hook intercepts the normal registration process. Instead of creating a user immediately, it creates a TempUser record with the username, bcrypt-hashed password, normalized email address, and a cryptographic verification token. A styled HTML email is sent with a verification link. The link expires after 12 hours. Clicking it creates the real user account, stores the verified email in user metadata, fires afterRegister hooks, generates a JWT, sets the auth cookie, and redirects to setup. The TempUser record is deleted. Duplicate registrations with the same email or username automatically clean up previous pending TempUser records. Password reset uses a separate token flow. The forgot-password endpoint accepts an email, looks up the user by the email stored in their metadata, generates a 256-bit reset token stored in user metadata with a 15-minute expiry, and sends a styled reset email. The reset endpoint validates the token, updates the password, clears the reset token, and invalidates all existing JWT sessions by writing a tokensInvalidBefore timestamp to the user's auth metadata. This forces re-login on all devices after a password change. Rate limiting protects the email endpoints. Forgot-password is capped at three requests per hour per IP. The response is deliberately identical whether the email exists or not, preventing enumeration. Email sending uses nodemailer with Gmail transport configured via EMAIL_USER and EMAIL_PASS environment variables. If html-rendering is installed, the forgot-password page renders as a styled HTML form.
v1.0.1 by TreeOS Site 0 downloads 9 files 1,106 lines 34.2 KB published 38d ago
treeos ext install email
View changelog

Manifest

Provides

  • 1 models
  • routes

Requires

  • services: auth
  • models: User

Optional

  • extensions: html-rendering
SHA256: 5606c2dbb220cf2b4056f6749b411df7064462b8f536f70c40f982480afe1364

Hooks

Listens To

  • beforeRegister
  • afterRegister

Environment Variables

KeyRequiredDescription
EMAIL_USER Yes Email account for sending (e.g. Gmail address)
EMAIL_PASS secret Yes Email account password or app password

Source Code

1import nodemailer from "nodemailer";
2
3function escapeHtml(str) {
4  return str
5    .replace(/&/g, "&")
6    .replace(/</g, "&lt;")
7    .replace(/>/g, "&gt;")
8    .replace(/"/g, "&quot;")
9    .replace(/'/g, "&#039;");
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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star email

Comments

Loading comments...

Post comments from the CLI: treeos ext comment email "your comment"
Max 3 comments per extension. One star and one flag per user.