Skip to content

Commit 5adf069

Browse files
authored
feat: self-serve password reset flow in user settings (#236)
1 parent 2cf611a commit 5adf069

7 files changed

Lines changed: 402 additions & 13 deletions

File tree

internal/api/src/routes/auth/auth.client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export const schemaResendPasswordResetResponse = z.object({
8585
ok: z.boolean(),
8686
});
8787

88+
export const schemaChangePasswordRequest = z.object({
89+
currentPassword: z.string(),
90+
newPassword: z.string().min(8),
91+
});
92+
93+
export const schemaChangePasswordResponse = z.object({
94+
ok: z.boolean(),
95+
});
96+
8897
export const schemaAuthProvider = z.object({
8998
id: z.string(),
9099
name: z.string(),
@@ -132,6 +141,10 @@ export type RequestPasswordResetResponse = z.infer<
132141
export type ResendPasswordResetResponse = z.infer<
133142
typeof schemaResendPasswordResetResponse
134143
>;
144+
export type ChangePasswordRequest = z.infer<typeof schemaChangePasswordRequest>;
145+
export type ChangePasswordResponse = z.infer<
146+
typeof schemaChangePasswordResponse
147+
>;
135148
export type AuthProvider = z.infer<typeof schemaAuthProvider>;
136149
export type GetProvidersResponse = z.infer<typeof schemaGetProvidersResponse>;
137150

@@ -354,6 +367,26 @@ export default class Auth {
354367
return response.json();
355368
}
356369

370+
/**
371+
* changePassword changes the user's password.
372+
* Requires authentication - user ID is taken from the session.
373+
*
374+
* @param request - The password change request.
375+
* @returns A promise that resolves to the response.
376+
*/
377+
public async changePassword(
378+
request: ChangePasswordRequest
379+
): Promise<ChangePasswordResponse> {
380+
const response = await this.client.request(
381+
"POST",
382+
"/api/auth/change-password",
383+
JSON.stringify(request)
384+
);
385+
386+
await assertResponseStatus(response, 200);
387+
return response.json();
388+
}
389+
357390
/**
358391
* getProviders returns the available authentication providers.
359392
*

internal/api/src/routes/auth/auth.server.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { hash } from "bcrypt-ts";
2+
import Client from "@blink.so/api";
23
import { afterEach, beforeEach, expect, test } from "bun:test";
34
import { http, HttpResponse } from "msw";
45
import { setupServer, SetupServerApi } from "msw/node";
@@ -1549,3 +1550,104 @@ test("POST /verify-email rejects suspended user", async () => {
15491550
const data = (await res.json()) as { error: string };
15501551
expect(data.error).toBe("Your account has been suspended");
15511552
});
1553+
1554+
// Change password tests
1555+
1556+
const changePasswordTestCases = [
1557+
{
1558+
name: "successfully changes password",
1559+
hasPassword: true,
1560+
currentPassword: "oldpassword123",
1561+
newPassword: "newpassword456",
1562+
sendCurrentPassword: "oldpassword123",
1563+
expectedOk: true,
1564+
},
1565+
{
1566+
name: "with wrong current password returns error",
1567+
hasPassword: true,
1568+
currentPassword: "correctpassword",
1569+
newPassword: "newpassword456",
1570+
sendCurrentPassword: "wrongpassword",
1571+
expectedError: "Current password is incorrect",
1572+
},
1573+
{
1574+
name: "for user without password returns error",
1575+
hasPassword: false,
1576+
currentPassword: null,
1577+
newPassword: "newpassword456",
1578+
sendCurrentPassword: "anypassword",
1579+
expectedError: "Password authentication is not enabled for this account",
1580+
},
1581+
{
1582+
name: "with short new password returns error",
1583+
hasPassword: true,
1584+
currentPassword: "oldpassword123",
1585+
newPassword: "short",
1586+
sendCurrentPassword: "oldpassword123",
1587+
expectedError: "Too small: expected string to have >=8 characters",
1588+
},
1589+
];
1590+
1591+
test.each(changePasswordTestCases)(
1592+
"POST /change-password $name",
1593+
async ({
1594+
hasPassword,
1595+
currentPassword,
1596+
newPassword,
1597+
sendCurrentPassword,
1598+
expectedOk,
1599+
expectedError,
1600+
}) => {
1601+
const { helpers, bindings } = await serve();
1602+
const db = await bindings.database();
1603+
1604+
const { user, client } = await helpers.createUser({
1605+
email: `changepass-${Date.now()}@example.com`,
1606+
});
1607+
1608+
await db.updateUserByID({
1609+
id: user.id,
1610+
password: hasPassword ? await hash(currentPassword!, 10) : null,
1611+
email_verified: new Date(),
1612+
});
1613+
1614+
let error: string | undefined;
1615+
const data = await client.auth
1616+
.changePassword({
1617+
currentPassword: sendCurrentPassword,
1618+
newPassword,
1619+
})
1620+
.catch((err) => {
1621+
error = err instanceof Error ? err.message : "Unknown error";
1622+
});
1623+
1624+
if (expectedOk) {
1625+
expect(data?.ok).toBe(true);
1626+
// Verify password was actually changed
1627+
const { compare } = await import("bcrypt-ts");
1628+
const updatedUser = await db.selectUserByID(user.id);
1629+
expect(await compare(newPassword, updatedUser!.password!)).toBe(true);
1630+
}
1631+
if (expectedError) {
1632+
expect(error).toContain(expectedError);
1633+
}
1634+
}
1635+
);
1636+
1637+
test("POST /change-password without authentication returns unauthorized error", async () => {
1638+
const { url } = await serve();
1639+
1640+
const client = new Client({ baseURL: url.toString() });
1641+
1642+
let error: string | undefined;
1643+
await client.auth
1644+
.changePassword({
1645+
currentPassword: "oldpassword123",
1646+
newPassword: "newpassword456",
1647+
})
1648+
.catch((err) => {
1649+
error = err instanceof Error ? err.message : "Unknown error";
1650+
});
1651+
1652+
expect(error).toContain("Unauthorized");
1653+
});

internal/api/src/routes/auth/auth.server.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@ import type { APIServer, Bindings } from "../../server";
1010
import { hashPassword } from "../../util/password";
1111
import { provisionUser } from "../provision-user";
1212
import {
13+
SESSION_COOKIE_NAME,
14+
SESSION_SECURE,
15+
schemaChangePasswordRequest,
1316
schemaRequestEmailChangeRequest,
1417
schemaRequestPasswordResetRequest,
1518
schemaResetPasswordRequest,
1619
schemaSignInWithCredentialsRequest,
1720
schemaSignupRequest,
1821
schemaVerifyEmailChangeRequest,
1922
schemaVerifyEmailRequest,
20-
SESSION_COOKIE_NAME,
21-
SESSION_SECURE,
2223
} from "./auth.client";
2324

2425
// ============================================================================
@@ -1311,4 +1312,44 @@ export default function mountAuth(server: APIServer) {
13111312

13121313
return c.json({ ok: true });
13131314
});
1315+
1316+
// POST /change-password - Change password for authenticated users
1317+
server.post(
1318+
"/change-password",
1319+
withAuth,
1320+
validator("json", (value) => {
1321+
return schemaChangePasswordRequest.parse(value);
1322+
}),
1323+
async (c) => {
1324+
const { currentPassword, newPassword } = c.req.valid("json");
1325+
const userId = c.get("user_id");
1326+
const db = await c.env.database();
1327+
1328+
// Get current user
1329+
const user = await db.selectUserByID(userId);
1330+
if (!user) {
1331+
return c.json({ error: "User not found" }, 404);
1332+
}
1333+
1334+
// Check if user has a password
1335+
if (!user.password) {
1336+
return c.json(
1337+
{ error: "Password authentication is not enabled for this account" },
1338+
400
1339+
);
1340+
}
1341+
1342+
// Verify current password
1343+
const passwordValid = await compare(currentPassword, user.password);
1344+
if (!passwordValid) {
1345+
return c.json({ error: "Current password is incorrect" }, 401);
1346+
}
1347+
1348+
// Hash and update password
1349+
const hashedPassword = await hashPassword(newPassword);
1350+
await db.updateUserByID({ id: userId, password: hashedPassword });
1351+
1352+
return c.json({ ok: true });
1353+
}
1354+
);
13141355
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Button } from "@/components/ui/button";
3+
import { withMockClient } from "@/lib/api-client.mock";
4+
import { ChangePasswordDialog } from "./change-password-dialog";
5+
6+
const meta: Meta<typeof ChangePasswordDialog> = {
7+
title: "Page/UserSettings/ChangePasswordDialog",
8+
component: ChangePasswordDialog,
9+
parameters: {
10+
layout: "centered",
11+
},
12+
decorators: [
13+
withMockClient((client) => {
14+
client.auth.changePassword.mockResolvedValue({ ok: true });
15+
}),
16+
],
17+
};
18+
19+
export default meta;
20+
21+
type Story = StoryObj<typeof meta>;
22+
23+
export const Default: Story = {
24+
render: () => (
25+
<ChangePasswordDialog
26+
trigger={
27+
<Button variant="outline" size="sm">
28+
Change
29+
</Button>
30+
}
31+
/>
32+
),
33+
};

0 commit comments

Comments
 (0)