Skip to content

Commit fbb861d

Browse files
committed
api-users: add users forget password
1 parent 6b33f4e commit fbb861d

File tree

3 files changed

+616
-0
lines changed

3 files changed

+616
-0
lines changed
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { $inject, t } from "@alepha/core";
2+
import { $action } from "@alepha/server";
3+
import { $passwordResetEmail } from "../descriptors/$passwordResetEmail.ts";
4+
import { SessionService } from "../services/SessionService.ts";
5+
6+
/**
7+
* Actions for password reset functionality.
8+
*
9+
* This class provides API endpoints for:
10+
* - Requesting a password reset (sends email with token)
11+
* - Validating a reset token
12+
* - Resetting the password with a valid token
13+
*/
14+
export class PasswordResetActions {
15+
protected readonly sessionService = $inject(SessionService);
16+
protected readonly passwordResetEmail = $passwordResetEmail();
17+
18+
/**
19+
* Request a password reset.
20+
* Generates a reset token and sends an email to the user.
21+
*
22+
* POST /api/password-reset/request
23+
*/
24+
public requestPasswordReset = $action({
25+
schema: {
26+
body: t.object({
27+
email: t.string({ format: "email" }),
28+
resetUrl: t.string(),
29+
}),
30+
response: t.object({
31+
success: t.boolean(),
32+
message: t.string(),
33+
}),
34+
},
35+
handler: async ({ body }) => {
36+
const expiresInMinutes = 60;
37+
const token = await this.sessionService.requestPasswordReset(
38+
body.email,
39+
expiresInMinutes,
40+
);
41+
42+
// Only send email if token was generated (user exists)
43+
if (token) {
44+
// Build the full reset URL with token
45+
const resetUrlWithToken = `${body.resetUrl}?token=${token}`;
46+
47+
await this.passwordResetEmail.send(body.email, {
48+
email: body.email,
49+
resetUrl: resetUrlWithToken,
50+
expiresInMinutes,
51+
});
52+
}
53+
54+
// Always return success to prevent email enumeration
55+
return {
56+
success: true,
57+
message:
58+
"If an account exists with this email, a password reset link has been sent.",
59+
};
60+
},
61+
});
62+
63+
/**
64+
* Validate a password reset token.
65+
* Checks if the token is valid and not expired.
66+
*
67+
* GET /api/password-reset/validate
68+
*/
69+
public validateResetToken = $action({
70+
schema: {
71+
query: t.object({
72+
token: t.string(),
73+
}),
74+
response: t.object({
75+
valid: t.boolean(),
76+
email: t.optional(t.string({ format: "email" })),
77+
}),
78+
},
79+
handler: async ({ query }) => {
80+
try {
81+
const email = await this.sessionService.validateResetToken(query.token);
82+
return {
83+
valid: true,
84+
email,
85+
};
86+
} catch (_error) {
87+
return {
88+
valid: false,
89+
};
90+
}
91+
},
92+
});
93+
94+
/**
95+
* Reset password with a valid token.
96+
* Updates the user's password and invalidates all sessions.
97+
*
98+
* POST /api/password-reset/reset
99+
*/
100+
public resetPassword = $action({
101+
schema: {
102+
body: t.object({
103+
token: t.string(),
104+
newPassword: t.string({ minLength: 8 }),
105+
}),
106+
response: t.object({
107+
success: t.boolean(),
108+
message: t.string(),
109+
}),
110+
},
111+
handler: async ({ body }) => {
112+
await this.sessionService.resetPassword(body.token, body.newPassword);
113+
114+
return {
115+
success: true,
116+
message: "Password has been reset successfully. Please log in.",
117+
};
118+
},
119+
});
120+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { t } from "@alepha/core";
2+
import { $email } from "@alepha/email";
3+
4+
/**
5+
* Password reset email template.
6+
*
7+
* This email is sent when a user requests to reset their password.
8+
* It includes a secure reset link with a time-limited token.
9+
*/
10+
export const $passwordResetEmail = () => {
11+
return $email({
12+
name: "Password Reset",
13+
subject: "Reset your password",
14+
body: `
15+
<h1>Reset Your Password</h1>
16+
<p>Hi {{email}},</p>
17+
<p>We received a request to reset your password. Click the link below to create a new password:</p>
18+
<p>
19+
<a href="{{resetUrl}}" style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 5px;">
20+
Reset Password
21+
</a>
22+
</p>
23+
<p>Or copy and paste this link into your browser:</p>
24+
<p>{{resetUrl}}</p>
25+
<p>This link will expire in {{expiresInMinutes}} minutes.</p>
26+
<p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
27+
<p>Best regards,<br>The Team</p>
28+
`,
29+
schema: t.object({
30+
email: t.string({ format: "email" }),
31+
resetUrl: t.string(),
32+
expiresInMinutes: t.number(),
33+
}),
34+
});
35+
};

0 commit comments

Comments
 (0)