diff --git a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts index 9eabf64..010f503 100644 --- a/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts +++ b/src/modules/auth/__tests__/integration/verifyEmail.v1.test.ts @@ -64,7 +64,7 @@ describe("Email Verification", () => { expect(verifyEmailRes.body.success).toBe(false); }); - it("should verify user's email after signup", async () => { + it("should verify user's email after signup and sign them in", async () => { const user = UserFactory.generate(); await request(app).post("/api/v1/auth/signup").send(user); @@ -78,6 +78,63 @@ describe("Email Verification", () => { expect(verifyEmailRes.status).toBe(200); expect(verifyEmailRes.body.success).toBe(true); + + // Should return user data + expect(verifyEmailRes.body.data.user).toBeDefined(); + expect(verifyEmailRes.body.data.user.email).toBe(user.email.toLowerCase()); + expect(verifyEmailRes.body.data.user.isEmailVerified).toBe(true); + + // Should set auth cookies + const cookies = verifyEmailRes.headers["set-cookie"]; + expect(cookies).toBeDefined(); + const cookieArray = Array.isArray(cookies) ? cookies : [cookies]; + const access = cookieArray.find((c: string) => + c.startsWith("access_token="), + ); + const refresh = cookieArray.find((c: string) => + c.startsWith("refresh_token="), + ); + + expect(access).toContain("HttpOnly"); + expect(access).toContain("SameSite=Lax"); + expect(access).toContain("Path=/"); + + expect(refresh).toContain("HttpOnly"); + expect(refresh).toContain("SameSite=Lax"); + expect(refresh).toContain("Path=/auth/refresh"); + }); + + it("should allow access to protected routes after email verification without separate login", async () => { + const user = UserFactory.generate(); + + await request(app).post("/api/v1/auth/signup").send(user); + + const verifyEmailRes = await request(app) + .post("/api/v1/auth/verify-email") + .send({ + email: user.email, + emailVerificationCode: getVerificationCode(), + }); + + expect(verifyEmailRes.status).toBe(200); + + // Extract the access_token cookie from the verify response + const cookies = verifyEmailRes.headers["set-cookie"]; + const cookieArray = (Array.isArray(cookies) ? cookies : [cookies]).filter( + (c): c is string => typeof c === "string", + ); + const accessCookie = cookieArray.find((c: string) => + c.startsWith("access_token="), + ); + + // Use the cookie to access a protected route + const meRes = await request(app) + .get("/api/v1/auth/me") + .set("Cookie", cookieArray); + + expect(meRes.status).toBe(200); + expect(meRes.body.success).toBe(true); + expect(meRes.body.data.user.email).toBe(user.email.toLowerCase()); }); it("should fail if user retries with the same code after being verified", async () => { @@ -94,6 +151,7 @@ describe("Email Verification", () => { expect(firstVerificationResponse.status).toBe(200); expect(firstVerificationResponse.body.success).toBe(true); + expect(firstVerificationResponse.body.data.user).toBeDefined(); const secondVerificationResponse = await request(app) .post("/api/v1/auth/verify-email") diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 4a6febf..dd69d9d 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -41,9 +41,12 @@ export const signup = routeTryCatcher( export const verifyEmailVerificationCode = routeTryCatcher( async (req: Request, res: Response, next: NextFunction) => { + const ip = req.ip; + const userAgent = req.get("User-Agent") || ""; const result = await AuthService.verifyEmailVerificationCode( req.body.emailVerificationCode, req.body.email, + { ip, userAgent }, ); if (!result.success) return next( @@ -51,9 +54,32 @@ export const verifyEmailVerificationCode = routeTryCatcher( (result as IErrorPayload).error || "Email verification failed", ), ); - return res - .status(200) - .json(result as ISuccessPayload); + + const data = ( + result as ISuccessPayload< + EmailVerificationOutput & { + accessToken: string; + refreshToken: string; + refreshTokenExpiresAt: Date; + } + > + ).data; + + setAuthCookies({ + res, + refreshToken: data.refreshToken, + refreshTokenExpiresAt: data.refreshTokenExpiresAt, + accessToken: data.accessToken, + }); + + return res.status(200).json({ + success: true, + data: { + email: data.email, + isEmailVerified: data.isEmailVerified, + user: data.user, + }, + } as ISuccessPayload); }, ); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0b83c0f..87cc98b 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -85,7 +85,20 @@ const AuthService = { verifyEmailVerificationCode: async ( code: string, email: string, - ): Promise | IErrorPayload> => { + metaData?: { + ip?: string | undefined; + userAgent?: string | undefined; + }, + ): Promise< + | ISuccessPayload< + EmailVerificationOutput & { + accessToken: string; + refreshToken: string; + refreshTokenExpiresAt: Date; + } + > + | IErrorPayload + > => { const user = await UserService.getUserByEmail(email); if (!user) return { @@ -106,7 +119,28 @@ const AuthService = { error: "Verification failed. Please check your email and try again", }; await user.clearEmailVerificationData(); - return { success: true, data: { email, isEmailVerified: true } }; + + // Create auth tokens to sign the user in immediately + const tokenResult = await AuthService.createTokensForUser( + user, + false, + metaData, + ); + + const { serializeUser } = await import("@modules/user/user.utils"); + const serializedUser = serializeUser(user); + + return { + success: true, + data: { + email, + isEmailVerified: true, + user: serializedUser as EmailVerificationOutput["user"], + accessToken: tokenResult.data.accessToken, + refreshToken: tokenResult.data.refreshToken, + refreshTokenExpiresAt: tokenResult.data.expiresAt, + }, + }; }, createTokensForUser: async ( user: IUser, diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index e36a66e..eba42d7 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -38,6 +38,7 @@ export type EmailVerificationInput = z.infer; export type EmailVerificationOutput = { email: string; isEmailVerified: boolean; + user: LoginOutput["user"]; }; export type resendEmailVerificationCodeInput = z.infer< @@ -52,6 +53,8 @@ export type LoginOutput = { firstName: string; lastName: string; role: string; + isOnboarded: boolean; + onboardingStep: number; createdAt: string; updatedAt: string; }; @@ -70,6 +73,8 @@ export type GetMeOutput = { firstName: string; lastName: string; role: string; + isOnboarded: boolean; + onboardingStep: number; isEmailVerified: boolean; createdAt: Date; updatedAt: Date; diff --git a/src/modules/user/routes/user.v1.routes.ts b/src/modules/user/routes/user.v1.routes.ts index b9b3242..11f8e20 100644 --- a/src/modules/user/routes/user.v1.routes.ts +++ b/src/modules/user/routes/user.v1.routes.ts @@ -6,7 +6,7 @@ import { updateMe } from "../user.controller"; const userRouter = Router(); -userRouter.put( +userRouter.patch( "/me", authenticate, validateResource(updateUserSchema), diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 38d0a35..92774ee 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -8,7 +8,12 @@ export const updateMe = routeTryCatcher(async (req: Request, res: Response) => { const input: UpdateUserInput = req.body; const update = Object.fromEntries( Object.entries(input).filter(([, v]) => v !== undefined), - ) as { firstName?: string; lastName?: string; isOnboarded?: boolean }; + ) as { + firstName?: string; + lastName?: string; + isOnboarded?: boolean; + onboardingStep?: number; + }; const updated = await UserService.updateUser( req.user!._id.toString(), update, diff --git a/src/modules/user/user.docs.ts b/src/modules/user/user.docs.ts index e74d7a2..d53040d 100644 --- a/src/modules/user/user.docs.ts +++ b/src/modules/user/user.docs.ts @@ -8,6 +8,7 @@ type UpdateUserOutput = { firstName: string; lastName: string; isOnboarded: boolean; + onboardingStep: number; isEmailVerified: boolean; createdAt: Date; updatedAt: Date; @@ -18,7 +19,7 @@ export type UserApiSpec = Tspec.DefineApiSpec<{ tags: ["Users"]; paths: { "/me": { - put: { + patch: { summary: "Update the currently authenticated user"; body: UpdateUserInput; responses: { diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 6e19996..1be53c3 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -21,6 +21,7 @@ const userSchema = new Schema( default: null, }, isOnboarded: { type: Boolean, default: false }, + onboardingStep: { type: Number, default: 0, min: 0, max: 4 }, }, { timestamps: true }, ); diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index 53c2417..4683eca 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -12,7 +12,12 @@ const UserService = { updateUser: async ( id: string, - input: { firstName?: string; lastName?: string; isOnboarded?: boolean }, + input: { + firstName?: string; + lastName?: string; + isOnboarded?: boolean; + onboardingStep?: number; + }, ): Promise => { return await UserModel.findByIdAndUpdate(id, input, { new: true }); }, diff --git a/src/modules/user/user.types.ts b/src/modules/user/user.types.ts index 19dc581..ca24379 100644 --- a/src/modules/user/user.types.ts +++ b/src/modules/user/user.types.ts @@ -22,4 +22,5 @@ export interface IUser extends mongoose.Document { verifyPasswordResetCode: (code: string) => boolean; clearPasswordResetData: () => Promise; isOnboarded: boolean; + onboardingStep: number; } diff --git a/src/modules/user/user.utils.ts b/src/modules/user/user.utils.ts index 14f1f5c..b7a4174 100644 --- a/src/modules/user/user.utils.ts +++ b/src/modules/user/user.utils.ts @@ -16,6 +16,7 @@ export function serializeUser(user: IUser) { firstName: obj.firstName, lastName: obj.lastName, isOnboarded: obj.isOnboarded, + onboardingStep: obj.onboardingStep, }; return safe; diff --git a/src/modules/user/user.validators.ts b/src/modules/user/user.validators.ts index 0ee6d57..1fa8587 100644 --- a/src/modules/user/user.validators.ts +++ b/src/modules/user/user.validators.ts @@ -4,6 +4,7 @@ export const updateUserSchema = z.object({ firstName: z.string().min(1).max(100).optional(), lastName: z.string().min(1).max(100).optional(), isOnboarded: z.boolean().optional(), + onboardingStep: z.number().min(0).max(4).optional(), }); export type UpdateUserInput = z.infer;