Skip to content

Commit 6966f01

Browse files
author
Lasim
committed
feat: implement password change notification email and update user account routing
4 parents 5b39887 + 350bdc4 + 2957728 + 516aa27 commit 6966f01

File tree

12 files changed

+1294
-1291
lines changed

12 files changed

+1294
-1291
lines changed

package-lock.json

Lines changed: 641 additions & 1266 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/backend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@
4444
"@release-it/conventional-changelog": "^10.0.1",
4545
"@types/better-sqlite3": "^7.6.13",
4646
"@types/fs-extra": "^11.0.4",
47-
"@types/jest": "^29.5.14",
47+
"@types/jest": "^30.0.0",
4848
"@types/nodemailer": "^6.4.14",
4949
"@types/pug": "^2.0.10",
5050
"@types/supertest": "^6.0.3",
5151
"@typescript-eslint/eslint-plugin": "^8.33.0",
5252
"@typescript-eslint/parser": "^8.33.0",
5353
"@vitest/coverage-v8": "^2.1.9",
5454
"drizzle-kit": "^0.31.1",
55-
"eslint": "^9.27.0",
55+
"eslint": "^9.29.0",
5656
"fs-extra": "^11.3.0",
5757
"jest": "^29.7.0",
5858
"release-it": "^19.0.2",
@@ -61,7 +61,7 @@
6161
"ts-node": "^10.9.2",
6262
"typescript": "^5.8.3",
6363
"typescript-eslint": "^8.33.0",
64-
"vitest": "^2.1.8"
64+
"vitest": "^3.2.3"
6565
},
6666
"overrides": {
6767
"glob": "^10.0.0",

services/backend/src/email/emailService.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,33 @@ export class EmailService {
300300
},
301301
});
302302
}
303+
304+
/**
305+
* Send a password changed notification email (type-safe helper)
306+
*/
307+
static async sendPasswordChangedEmail(options: {
308+
to: string;
309+
userName?: string;
310+
userEmail: string;
311+
changeTime: string;
312+
ipAddress?: string;
313+
userAgent?: string;
314+
loginUrl?: string;
315+
supportEmail?: string;
316+
}): Promise<EmailSendResult> {
317+
return this.sendEmail({
318+
to: options.to,
319+
subject: 'Password Changed - DeployStack Security Alert',
320+
template: 'password-changed',
321+
variables: {
322+
userName: options.userName,
323+
userEmail: options.userEmail,
324+
changeTime: options.changeTime,
325+
ipAddress: options.ipAddress,
326+
userAgent: options.userAgent,
327+
loginUrl: options.loginUrl || 'https://app.deploystack.com/login',
328+
supportEmail: options.supportEmail || 'support@deploystack.com',
329+
},
330+
});
331+
}
303332
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//- @description Password change notification email template
2+
//- @variables userName, userEmail, changeTime, ipAddress, userAgent
3+
extends layouts/base.pug
4+
5+
block content
6+
h1 Password Changed Successfully
7+
8+
if userName
9+
p
10+
| Hi #{userName},
11+
else
12+
p
13+
| Hello,
14+
15+
p
16+
| We're writing to confirm that your password for your DeployStack account (#{userEmail}) has been successfully changed.
17+
18+
.info-box(style="background-color: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 8px; padding: 16px; margin: 20px 0;")
19+
h3(style="margin-top: 0; color: #0369a1;") Change Details:
20+
p(style="margin: 8px 0;")
21+
strong Time:
22+
| #{changeTime}
23+
if ipAddress
24+
p(style="margin: 8px 0;")
25+
strong IP Address:
26+
| #{ipAddress}
27+
if userAgent
28+
p(style="margin: 8px 0;")
29+
strong Device/Browser:
30+
| #{userAgent}
31+
32+
p
33+
| If you made this change, no further action is required. Your account is secure.
34+
35+
.warning-box(style="background-color: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 16px; margin: 20px 0;")
36+
h3(style="margin-top: 0; color: #dc2626;") ⚠️ Didn't make this change?
37+
p
38+
| If you did not change your password, your account may have been compromised. Please:
39+
ul
40+
li Immediately log into your account and change your password
41+
li Review your account activity for any suspicious actions
42+
li Contact our support team if you need assistance
43+
44+
.text-center(style="margin-top: 16px;")
45+
a.button(href="#{loginUrl || 'https://app.deploystack.com/login'}" style="background-color: #dc2626; color: white;") Secure My Account
46+
47+
p
48+
| For your security, we recommend:
49+
ul
50+
li Using a strong, unique password
51+
li Enabling two-factor authentication if available
52+
li Regularly reviewing your account activity
53+
54+
if supportEmail
55+
p
56+
| If you have any questions or concerns, please contact us at
57+
a(href=`mailto:${supportEmail}`)= supportEmail
58+
| .
59+
60+
p.text-muted
61+
| Best regards,
62+
br
63+
| The DeployStack Security Team
64+
65+
hr(style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;")
66+
67+
p.text-muted(style="font-size: 12px; color: #6b7280;")
68+
| This is an automated security notification. Please do not reply to this email.
69+
br
70+
| If you're having trouble with the button above, copy and paste the following URL into your browser:
71+
br
72+
span(style="word-break: break-all; font-family: monospace;")= loginUrl || 'https://app.deploystack.com/login'

services/backend/src/email/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,23 @@ export interface EmailVerificationVariables {
120120
supportEmail?: string;
121121
}
122122

123+
export interface PasswordChangedEmailVariables {
124+
userName?: string;
125+
userEmail: string;
126+
changeTime: string;
127+
ipAddress?: string;
128+
userAgent?: string;
129+
loginUrl?: string;
130+
supportEmail?: string;
131+
}
132+
123133
// Template registry for type safety
124134
export interface TemplateVariableMap {
125135
welcome: WelcomeEmailVariables;
126136
'password-reset': PasswordResetEmailVariables;
127137
notification: NotificationEmailVariables;
128138
'email-verification': EmailVerificationVariables;
139+
'password-changed': PasswordChangedEmailVariables;
129140
}
130141

131142
export type TemplateNames = keyof TemplateVariableMap;

services/backend/src/routes/auth/changePassword.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { ChangePasswordSchema, type ChangePasswordInput } from './schemas';
66
import { requireAuthHook } from '../../hooks/authHook';
77
import { z } from 'zod';
88
import { zodToJsonSchema } from 'zod-to-json-schema';
9+
import { EmailService } from '../../email';
10+
import { GlobalSettingsService } from '../../services/globalSettingsService';
911

1012
// Response schemas
1113
const changePasswordSuccessResponseSchema = z.object({
@@ -160,6 +162,56 @@ export default async function changePasswordRoute(fastify: FastifyInstance) {
160162

161163
fastify.log.info(`Password changed successfully for user: ${userId}`);
162164

165+
// Send password change notification email if email sending is enabled
166+
try {
167+
// Check if email sending is enabled in global settings
168+
const emailSettings = await GlobalSettingsService.getByGroup('global');
169+
const sendMailSetting = emailSettings?.find(s => s.key === 'global.send_mail');
170+
const isEmailEnabled = sendMailSetting?.value === 'true';
171+
172+
if (isEmailEnabled) {
173+
// Get user's IP address and user agent for security info
174+
const ipAddress = request.ip || request.headers['x-forwarded-for'] as string || 'Unknown';
175+
const userAgent = request.headers['user-agent'] || 'Unknown';
176+
const changeTime = new Date().toLocaleString('en-US', {
177+
timeZone: 'UTC',
178+
year: 'numeric',
179+
month: 'long',
180+
day: 'numeric',
181+
hour: '2-digit',
182+
minute: '2-digit',
183+
timeZoneName: 'short'
184+
});
185+
186+
// Get frontend URL for login link
187+
const frontendUrlSetting = emailSettings?.find(s => s.key === 'global.frontend_url');
188+
const frontendUrl = frontendUrlSetting?.value || process.env.DEPLOYSTACK_FRONTEND_URL || 'https://app.deploystack.com';
189+
const loginUrl = `${frontendUrl}/login`;
190+
191+
// Send password change notification email
192+
const emailResult = await EmailService.sendPasswordChangedEmail({
193+
to: user.email,
194+
userName: user.first_name ? `${user.first_name} ${user.last_name || ''}`.trim() : user.username,
195+
userEmail: user.email,
196+
changeTime,
197+
ipAddress,
198+
userAgent,
199+
loginUrl,
200+
});
201+
202+
if (emailResult.success) {
203+
fastify.log.info(`Password change notification email sent to: ${user.email}`);
204+
} else {
205+
fastify.log.warn(`Failed to send password change notification email: ${emailResult.error}`);
206+
}
207+
} else {
208+
fastify.log.debug('Email sending is disabled, skipping password change notification');
209+
}
210+
} catch (emailError) {
211+
// Don't fail the password change if email fails
212+
fastify.log.warn('Failed to send password change notification email:', emailError);
213+
}
214+
163215
// Optional: Invalidate all other sessions for security
164216
// This would require additional implementation to track and invalidate sessions
165217
// For now, we'll just log this as a security consideration

services/backend/src/routes/auth/updateProfile.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const updateProfileRouteSchema = {
4949
$refStrategy: 'none',
5050
target: 'openApi3'
5151
}),
52+
403: zodToJsonSchema(updateProfileErrorResponseSchema.describe('Forbidden - Cannot change username for non-email users'), {
53+
$refStrategy: 'none',
54+
target: 'openApi3'
55+
}),
5256
500: zodToJsonSchema(updateProfileErrorResponseSchema.describe('Internal Server Error - Profile update failed'), {
5357
$refStrategy: 'none',
5458
target: 'openApi3'
@@ -114,6 +118,14 @@ export default async function updateProfileRoute(fastify: FastifyInstance) {
114118

115119
const currentUser = users[0];
116120

121+
// Check if username change is allowed for this auth type
122+
if (username && username !== currentUser.username && currentUser.auth_type !== 'email_signup') {
123+
return reply.status(403).send({
124+
success: false,
125+
error: 'Username change is only available for email-authenticated users.'
126+
});
127+
}
128+
117129
// If username is being updated, check if it's already taken by another user
118130
if (username && username !== currentUser.username) {
119131
// eslint-disable-next-line @typescript-eslint/no-explicit-any

services/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"@vue/eslint-config-typescript": "^14.5.0",
4444
"@vue/tsconfig": "^0.7.0",
4545
"autoprefixer": "^10.4.21",
46-
"eslint": "^9.27.0",
46+
"eslint": "^9.29.0",
4747
"eslint-plugin-vue": "~10.1.0",
4848
"jiti": "^2.4.2",
4949
"npm-run-all2": "^8.0.4",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { useRoute, RouterLink } from 'vue-router'
4+
import { cn } from '@/lib/utils'
5+
import { Button } from '@/components/ui/button'
6+
7+
export interface AccountSection {
8+
id: string
9+
name: string
10+
href: string
11+
}
12+
13+
interface Props {
14+
canChangePassword?: boolean
15+
}
16+
17+
const props = withDefaults(defineProps<Props>(), {
18+
canChangePassword: true
19+
})
20+
21+
const route = useRoute()
22+
23+
const accountSections = computed((): AccountSection[] => {
24+
const sections = [
25+
{
26+
id: 'profile',
27+
name: 'Profile',
28+
href: '/user/account/profile',
29+
},
30+
]
31+
32+
// Only show Security section for users who can change password
33+
if (props.canChangePassword) {
34+
sections.push({
35+
id: 'security',
36+
name: 'Security',
37+
href: '/user/account/security',
38+
})
39+
}
40+
41+
return sections
42+
})
43+
</script>
44+
45+
<template>
46+
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
47+
<Button
48+
v-for="section in accountSections"
49+
:key="section.id"
50+
as-child
51+
variant="ghost"
52+
:class="cn(
53+
'w-full text-left justify-start',
54+
route.path === section.href && 'bg-muted hover:bg-muted',
55+
)"
56+
>
57+
<RouterLink :to="section.href">
58+
{{ section.name }}
59+
</RouterLink>
60+
</Button>
61+
</nav>
62+
</template>

services/frontend/src/router/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ const routes = [
6363
},
6464
{
6565
path: '/user/account',
66+
redirect: '/user/account/profile'
67+
},
68+
{
69+
path: '/user/account/:section',
6670
name: 'UserAccount',
67-
component: () => import('../views/UserAccount.vue'),
71+
component: () => import('../views/user/Account.vue'),
6872
meta: { requiresSetup: true },
6973
},
7074
{

0 commit comments

Comments
 (0)