Skip to content

Commit da37fd6

Browse files
committed
#35
1 parent 026512d commit da37fd6

6 files changed

Lines changed: 220 additions & 35 deletions

File tree

src/app/api/admin/smtp-settings/test/route.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { getCurrentUser } from "@/lib/auth";
3-
import { getSmtpSettings, isSmtpEnabled, sendMail } from "@/lib/mail";
3+
import { buildSmtpTestMail, getSmtpSettings, isSmtpEnabled, sendMail } from "@/lib/mail";
44
import { writeAuditLog } from "@/lib/audit";
55

66
export async function POST(req: NextRequest) {
@@ -26,15 +26,17 @@ export async function POST(req: NextRequest) {
2626
}
2727

2828
try {
29+
const smtpTestMail = buildSmtpTestMail({
30+
host: smtp.host,
31+
port: smtp.port,
32+
secure: smtp.secure,
33+
fromEmail: smtp.fromEmail,
34+
});
2935
await sendMail({
3036
to: target,
31-
subject: "ServerCommander SMTP Test",
32-
text:
33-
`This is a test email from ServerCommander.\n\n` +
34-
`Host: ${smtp.host}\n` +
35-
`Port: ${smtp.port}\n` +
36-
`Encrypted transport: ${smtp.secure ? "enabled" : "disabled"}\n` +
37-
`From: ${smtp.fromEmail}`,
37+
subject: smtpTestMail.subject,
38+
text: smtpTestMail.text,
39+
html: smtpTestMail.html,
3840
});
3941

4042
await writeAuditLog(

src/app/api/auth/login/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createSession, setSessionCookie } from "@/lib/auth";
55
import { writeAuditLog } from "@/lib/audit";
66
import { getLoginRateLimit } from "@/lib/rate-limit";
77
import { issueAuthCode } from "@/lib/auth-codes";
8-
import { isSmtpEnabled, sendMail } from "@/lib/mail";
8+
import { buildLoginCodeMail, isSmtpEnabled, sendMail } from "@/lib/mail";
99

1010
export async function POST(req: NextRequest) {
1111
try {
@@ -98,10 +98,16 @@ export async function POST(req: NextRequest) {
9898
}
9999

100100
const challenge = await issueAuthCode(user.id, "LOGIN_2FA", 300);
101+
const loginCodeMail = buildLoginCodeMail({
102+
displayName: user.displayName ?? user.username,
103+
code: challenge.code,
104+
minutesValid: 5,
105+
});
101106
await sendMail({
102107
to: user.email,
103-
subject: "ServerCommander Login Code",
104-
text: `Hello ${user.displayName ?? user.username},\n\nYour login code is: ${challenge.code}\n\nThis code expires in 5 minutes.\n\nIf you did not request this login, contact your administrator.`,
108+
subject: loginCodeMail.subject,
109+
text: loginCodeMail.text,
110+
html: loginCodeMail.html,
105111
});
106112

107113
return NextResponse.json({

src/app/api/auth/password-reset/request/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { db } from "@/lib/db";
33
import { issueAuthCode } from "@/lib/auth-codes";
4-
import { isSmtpEnabled, sendMail } from "@/lib/mail";
4+
import { buildPasswordResetCodeMail, isSmtpEnabled, sendMail } from "@/lib/mail";
55

66
export async function POST(req: NextRequest) {
77
try {
@@ -32,10 +32,16 @@ export async function POST(req: NextRequest) {
3232
}
3333

3434
const challenge = await issueAuthCode(user.id, "PASSWORD_RESET", 600);
35+
const resetMail = buildPasswordResetCodeMail({
36+
displayName: user.displayName ?? user.username,
37+
code: challenge.code,
38+
minutesValid: 10,
39+
});
3540
await sendMail({
3641
to: user.email,
37-
subject: "ServerCommander Password Reset Code",
38-
text: `Hello ${user.displayName ?? user.username},\n\nYour password reset code is: ${challenge.code}\n\nThis code expires in 10 minutes.`,
42+
subject: resetMail.subject,
43+
text: resetMail.text,
44+
html: resetMail.html,
3945
});
4046

4147
return NextResponse.json({ success: true, routedToCodeEntry: true });

src/app/api/users/route.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
33
import { db } from "@/lib/db";
44
import bcrypt from "bcryptjs";
55
import { writeAuditLog } from "@/lib/audit";
6-
import { isSmtpEnabled, sendMail } from "@/lib/mail";
6+
import { buildWelcomeCredentialsMail, isSmtpEnabled, sendMail } from "@/lib/mail";
77
import { randomBytes } from "crypto";
88

99
function generateTemporaryPassword(length = 14): string {
@@ -65,16 +65,20 @@ export async function POST(req: NextRequest) {
6565
if (currentUser.role !== "ADMIN") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
6666

6767
const body = await req.json();
68-
const { username, email, displayName, permissions } = body;
68+
const { username, email, permissions } = body;
6969

7070
if (!username) {
7171
return NextResponse.json({ error: "username is required" }, { status: 400 });
7272
}
7373

7474
const smtpEnabled = await isSmtpEnabled();
75+
if (!smtpEnabled) {
76+
return NextResponse.json({ error: "SMTP must be configured before creating users" }, { status: 400 });
77+
}
78+
7579
const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
76-
if (smtpEnabled && !normalizedEmail) {
77-
return NextResponse.json({ error: "email is required while SMTP is enabled" }, { status: 400 });
80+
if (!normalizedEmail) {
81+
return NextResponse.json({ error: "email is required" }, { status: 400 });
7882
}
7983

8084
const existing = await db.user.findUnique({ where: { username } });
@@ -95,9 +99,9 @@ export async function POST(req: NextRequest) {
9599
const newUser = await db.user.create({
96100
data: {
97101
username,
98-
email: normalizedEmail || null,
102+
email: normalizedEmail,
99103
passwordHash,
100-
displayName: displayName ?? null,
104+
displayName: null,
101105
role: "USER",
102106
mustChangePassword: true,
103107
permissions: permissions
@@ -131,19 +135,22 @@ export async function POST(req: NextRequest) {
131135
req
132136
);
133137

134-
if (smtpEnabled && normalizedEmail) {
135-
await sendMail({
136-
to: normalizedEmail,
137-
subject: "Welcome to ServerCommander",
138-
text: `Hello ${displayName?.trim() || username},\n\nYour ServerCommander account has been created.\n\nUsername: ${username}\nTemporary password: ${temporaryPassword}\n\nAt first login you must set your own password.`,
139-
});
140-
}
138+
const welcomeMail = buildWelcomeCredentialsMail({
139+
displayName: username,
140+
username,
141+
temporaryPassword,
142+
});
143+
await sendMail({
144+
to: normalizedEmail,
145+
subject: welcomeMail.subject,
146+
text: welcomeMail.text,
147+
html: welcomeMail.html,
148+
});
141149

142150
return NextResponse.json(
143151
{
144152
user: { id: newUser.id, username: newUser.username },
145-
temporaryPassword: smtpEnabled ? undefined : temporaryPassword,
146-
mailSent: smtpEnabled && !!normalizedEmail,
153+
mailSent: true,
147154
},
148155
{ status: 201 }
149156
);

src/components/users/UsersTable.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,7 @@ function CreateGroupModal({ onClose, onCreated }: { onClose: () => void; onCreat
576576

577577
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
578578
const [username, setUsername] = useState("");
579-
const [password, setPassword] = useState("");
580-
const [displayName, setDisplayName] = useState("");
579+
const [email, setEmail] = useState("");
581580
const [error, setError] = useState("");
582581
const [loading, setLoading] = useState(false);
583582

@@ -588,7 +587,7 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
588587
const res = await fetch("/api/users", {
589588
method: "POST",
590589
headers: { "Content-Type": "application/json" },
591-
body: JSON.stringify({ username, password, displayName: displayName || undefined }),
590+
body: JSON.stringify({ username, email }),
592591
});
593592
setLoading(false);
594593
if (res.ok) {
@@ -605,8 +604,10 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
605604
<h2 className="text-lg font-bold mb-4">Create User</h2>
606605
<form onSubmit={handleCreate} className="space-y-4">
607606
<Field label="Username" value={username} onChange={setUsername} required />
608-
<Field label="Display Name (optional)" value={displayName} onChange={setDisplayName} />
609-
<Field label="Password" value={password} onChange={setPassword} type="password" required />
607+
<Field label="Email" value={email} onChange={setEmail} type="email" required />
608+
<p className="text-xs text-muted-foreground">
609+
A secure temporary password is generated automatically and sent to this email address.
610+
</p>
610611
{error && <p className="text-sm text-destructive">{error}</p>}
611612
<div className="flex gap-3 pt-2">
612613
<button type="button" onClick={onClose} className="flex-1 rounded-lg border border-border px-4 py-2.5 text-sm hover:bg-accent transition">

src/lib/mail.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,168 @@ import { decryptSecret } from "@/lib/secrets";
55
export type MailPayload = {
66
to: string;
77
subject: string;
8-
text: string;
8+
text?: string;
9+
html?: string;
910
};
1011

12+
function escapeHtml(value: string): string {
13+
return value
14+
.replace(/&/g, "&amp;")
15+
.replace(/</g, "&lt;")
16+
.replace(/>/g, "&gt;")
17+
.replace(/\"/g, "&quot;")
18+
.replace(/'/g, "&#39;");
19+
}
20+
21+
function wrapMailHtml(title: string, contentHtml: string, footer?: string): string {
22+
return `<!doctype html>
23+
<html lang="en">
24+
<body style="margin:0;padding:0;background:#f3f6fb;font-family:'Segoe UI',Arial,sans-serif;color:#0f172a;">
25+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="padding:28px 12px;">
26+
<tr>
27+
<td align="center">
28+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:620px;background:#ffffff;border:1px solid #dbe4f0;border-radius:14px;overflow:hidden;">
29+
<tr>
30+
<td style="padding:20px 24px;background:linear-gradient(120deg,#0f172a,#1e293b);color:#ffffff;">
31+
<h1 style="margin:0;font-size:20px;line-height:1.2;font-weight:700;">ServerCommander</h1>
32+
<p style="margin:8px 0 0 0;font-size:13px;opacity:0.9;">${escapeHtml(title)}</p>
33+
</td>
34+
</tr>
35+
<tr>
36+
<td style="padding:24px;">
37+
${contentHtml}
38+
</td>
39+
</tr>
40+
<tr>
41+
<td style="padding:14px 24px;background:#f8fafc;border-top:1px solid #e2e8f0;color:#475569;font-size:12px;line-height:1.5;">
42+
${escapeHtml(footer ?? "Automatic security message from ServerCommander.")}
43+
</td>
44+
</tr>
45+
</table>
46+
</td>
47+
</tr>
48+
</table>
49+
</body>
50+
</html>`;
51+
}
52+
53+
export function buildWelcomeCredentialsMail(input: {
54+
displayName: string;
55+
username: string;
56+
temporaryPassword: string;
57+
}): { subject: string; text: string; html: string } {
58+
const subject = "Welcome to ServerCommander";
59+
const text = [
60+
`Hello ${input.displayName},`,
61+
"",
62+
"Your ServerCommander account has been created.",
63+
`Username: ${input.username}`,
64+
`Temporary password: ${input.temporaryPassword}`,
65+
"",
66+
"At first login you must change your password.",
67+
].join("\n");
68+
69+
const html = wrapMailHtml(
70+
"Your account is ready",
71+
`<p style="margin:0 0 12px 0;font-size:15px;">Hello ${escapeHtml(input.displayName)},</p>
72+
<p style="margin:0 0 16px 0;font-size:14px;color:#334155;">Your ServerCommander account was created. Use these initial credentials:</p>
73+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc;">
74+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;">Username</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;">${escapeHtml(input.username)}</td></tr>
75+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Temporary password</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${escapeHtml(input.temporaryPassword)}</td></tr>
76+
</table>
77+
<p style="margin:16px 0 0 0;font-size:13px;color:#334155;">You will be asked to change this password at first login.</p>`,
78+
"Do not share this email. If this account was not expected, contact an administrator immediately."
79+
);
80+
81+
return { subject, text, html };
82+
}
83+
84+
export function buildLoginCodeMail(input: {
85+
displayName: string;
86+
code: string;
87+
minutesValid: number;
88+
}): { subject: string; text: string; html: string } {
89+
const subject = "ServerCommander Login Code";
90+
const text = [
91+
`Hello ${input.displayName},`,
92+
"",
93+
`Your login code is: ${input.code}`,
94+
`This code expires in ${input.minutesValid} minutes.`,
95+
"",
96+
"If you did not request this login, contact your administrator.",
97+
].join("\n");
98+
99+
const html = wrapMailHtml(
100+
"Two-factor authentication",
101+
`<p style="margin:0 0 12px 0;font-size:15px;">Hello ${escapeHtml(input.displayName)},</p>
102+
<p style="margin:0 0 14px 0;font-size:14px;color:#334155;">Use this one-time login code:</p>
103+
<div style="display:inline-block;padding:12px 18px;border-radius:10px;background:#0f172a;color:#ffffff;font-size:26px;font-weight:700;letter-spacing:4px;">${escapeHtml(input.code)}</div>
104+
<p style="margin:14px 0 0 0;font-size:13px;color:#334155;">This code expires in ${input.minutesValid} minutes.</p>`,
105+
"Never share this code with anyone."
106+
);
107+
108+
return { subject, text, html };
109+
}
110+
111+
export function buildPasswordResetCodeMail(input: {
112+
displayName: string;
113+
code: string;
114+
minutesValid: number;
115+
}): { subject: string; text: string; html: string } {
116+
const subject = "ServerCommander Password Reset Code";
117+
const text = [
118+
`Hello ${input.displayName},`,
119+
"",
120+
`Your password reset code is: ${input.code}`,
121+
`This code expires in ${input.minutesValid} minutes.`,
122+
].join("\n");
123+
124+
const html = wrapMailHtml(
125+
"Password reset request",
126+
`<p style="margin:0 0 12px 0;font-size:15px;">Hello ${escapeHtml(input.displayName)},</p>
127+
<p style="margin:0 0 14px 0;font-size:14px;color:#334155;">Use this one-time code to reset your password:</p>
128+
<div style="display:inline-block;padding:12px 18px;border-radius:10px;background:#0f172a;color:#ffffff;font-size:26px;font-weight:700;letter-spacing:4px;">${escapeHtml(input.code)}</div>
129+
<p style="margin:14px 0 0 0;font-size:13px;color:#334155;">This code expires in ${input.minutesValid} minutes.</p>`,
130+
"If you did not request a reset, you can ignore this message."
131+
);
132+
133+
return { subject, text, html };
134+
}
135+
136+
export function buildSmtpTestMail(input: {
137+
host: string;
138+
port: number;
139+
secure: boolean;
140+
fromEmail: string;
141+
}): { subject: string; text: string; html: string } {
142+
const subject = "ServerCommander SMTP Test";
143+
const transport = input.secure
144+
? (input.port === 465 ? "SSL/TLS (implicit)" : "STARTTLS")
145+
: "unencrypted/optional TLS";
146+
const text = [
147+
"This is a test email from ServerCommander.",
148+
"",
149+
`Host: ${input.host}`,
150+
`Port: ${input.port}`,
151+
`Transport: ${transport}`,
152+
`From: ${input.fromEmail}`,
153+
].join("\n");
154+
155+
const html = wrapMailHtml(
156+
"SMTP test successful",
157+
`<p style="margin:0 0 12px 0;font-size:15px;">This confirms that outgoing email is working.</p>
158+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc;">
159+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;">Host</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;">${escapeHtml(input.host)}</td></tr>
160+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Port</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${input.port}</td></tr>
161+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Transport</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${escapeHtml(transport)}</td></tr>
162+
<tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">From</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${escapeHtml(input.fromEmail)}</td></tr>
163+
</table>`,
164+
"No further action required."
165+
);
166+
167+
return { subject, text, html };
168+
}
169+
11170
export async function getSmtpSettings() {
12171
return db.smtpSettings.findUnique({ where: { id: "default" } });
13172
}
@@ -25,6 +184,9 @@ export async function sendMail(payload: MailPayload): Promise<void> {
25184
if (!cfg.host || !cfg.port || !cfg.username || !cfg.passwordEnc || !cfg.fromEmail) {
26185
throw new Error("SMTP config incomplete");
27186
}
187+
if (!payload.text && !payload.html) {
188+
throw new Error("Mail payload requires text or html content");
189+
}
28190

29191
const useImplicitTls = cfg.secure && cfg.port === 465;
30192
const requireStartTls = cfg.secure && cfg.port !== 465;
@@ -49,5 +211,6 @@ export async function sendMail(payload: MailPayload): Promise<void> {
49211
to: payload.to,
50212
subject: payload.subject,
51213
text: payload.text,
214+
html: payload.html,
52215
});
53216
}

0 commit comments

Comments
 (0)