diff --git a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts index f45207c..6b48812 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts @@ -1,6 +1,6 @@ import { DatabasePg } from "../../common/index"; import { INestApplication } from "@nestjs/common"; -import { isArray } from "lodash"; +import { isArray, omit } from "lodash"; import request from "supertest"; import { createUserFactory } from "../../../test/factory/user.factory"; import { createE2ETest } from "../../../test/create-e2e-test"; @@ -137,6 +137,7 @@ describe("AuthController (e2e)", () => { const user = await userFactory.build(); const password = "password123"; await authService.register(user.email, password); + let refreshToken = ""; const loginResponse = await request(app.getHttpServer()) .post("/auth/login") @@ -148,8 +149,6 @@ describe("AuthController (e2e)", () => { const cookies = loginResponse.headers["set-cookie"]; - let refreshToken = ""; - if (isArray(cookies)) { cookies.forEach((cookie) => { if (cookie.startsWith("refresh_token=")) { @@ -174,4 +173,43 @@ describe("AuthController (e2e)", () => { .expect(401); }); }); + + describe("GET /auth/current-user", () => { + it("should return current user data for authenticated user", async () => { + let accessToken = ""; + + const user = await userFactory + .withCredentials({ password: "password123" }) + .create(); + + const loginResponse = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: user.email, + password: "password123", + }); + + const cookies = loginResponse.headers["set-cookie"]; + + if (Array.isArray(cookies)) { + cookies.forEach((cookieString) => { + const parsedCookie = cookie.parse(cookieString); + if ("access_token" in parsedCookie) { + accessToken = parsedCookie.access_token; + } + }); + } + + const response = await request(app.getHttpServer()) + .get("/auth/current-user") + .set("Cookie", `access_token=${accessToken};`) + .expect(200); + + expect(response.body.data).toStrictEqual(omit(user, "credentials")); + }); + + it("should return 401 for unauthenticated request", async () => { + await request(app.getHttpServer()).get("/auth/current-user").expect(401); + }); + }); }); diff --git a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts index 3ffcff8..b23c989 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts @@ -1,4 +1,8 @@ -import { ConflictException, UnauthorizedException } from "@nestjs/common"; +import { + ConflictException, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; import * as bcrypt from "bcrypt"; import { eq } from "drizzle-orm"; import { AuthService } from "src/auth/auth.service"; @@ -136,4 +140,24 @@ describe("AuthService", () => { expect(result).toBeNull(); }); }); + + describe("currentUser", () => { + it("should return current user data", async () => { + const user = await userFactory.create(); + + const result = await authService.currentUser(user.id); + + expect(result).toBeDefined(); + expect(result.id).toBe(user.id); + expect(result.email).toBe(user.email); + }); + + it("should throw UnauthorizedException for non-existent user", async () => { + const nonExistentUserId = crypto.randomUUID(); + + await expect(authService.currentUser(nonExistentUserId)).rejects.toThrow( + NotFoundException, + ); + }); + }); }); diff --git a/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts b/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts index 3216165..304f063 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts @@ -6,6 +6,7 @@ import { Res, UnauthorizedException, UseGuards, + Get, } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; import { Static } from "@sinclair/typebox"; @@ -23,6 +24,7 @@ import { } from "../schemas/create-account.schema"; import { LoginBody, loginSchema } from "../schemas/login.schema"; import { TokenService } from "../token.service"; +import { CurrentUser } from "src/common/decorators/user.decorator"; @Controller("auth") export class AuthController { @@ -97,4 +99,16 @@ export class AuthController { return null; } + + @Get("current-user") + @Validate({ + response: baseResponse(commonUserSchema), + }) + async currentUser( + @CurrentUser() currentUser: { userId: string }, + ): Promise>> { + const account = await this.authService.currentUser(currentUser.userId); + + return new BaseResponse(account); + } } diff --git a/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts b/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts index a8d472f..685d64a 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts @@ -8,7 +8,7 @@ import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import * as bcrypt from "bcrypt"; import { eq } from "drizzle-orm"; -import { DatabasePg } from "src/common"; +import { DatabasePg, UUIDType } from "src/common"; import { credentials, users } from "../storage/schema"; import { UsersService } from "../users/users.service"; import hashPassword from "src/common/helpers/hashPassword"; @@ -63,6 +63,16 @@ export class AuthService { }; } + public async currentUser(id: UUIDType) { + const user = await this.usersService.getUserById(id); + + if (!user) { + throw new UnauthorizedException("User not found"); + } + + return user; + } + public async refreshTokens(refreshToken: string) { try { const payload = await this.jwtService.verifyAsync(refreshToken, { diff --git a/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json b/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json index fc3d61b..c23b8c1 100644 --- a/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json +++ b/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json @@ -89,6 +89,23 @@ } } }, + "/auth/current-user": { + "get": { + "operationId": "AuthController_currentUser", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserResponse" + } + } + } + } + } + } + }, "/users": { "get": { "operationId": "UsersController_getUsers", @@ -345,6 +362,37 @@ "RefreshTokensResponse": { "type": "null" }, + "CurrentUserResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "email" + ] + } + }, + "required": [ + "data" + ] + }, "GetUsersResponse": { "type": "object", "properties": { diff --git a/examples/common_nestjs_remix/apps/web/app/api/api-client.ts b/examples/common_nestjs_remix/apps/web/app/api/api-client.ts index 849dfca..8df673b 100644 --- a/examples/common_nestjs_remix/apps/web/app/api/api-client.ts +++ b/examples/common_nestjs_remix/apps/web/app/api/api-client.ts @@ -10,16 +10,18 @@ export const ApiClient = new API({ ApiClient.instance.interceptors.response.use( (response) => response, async (error) => { - const isLoggedIn = useAuthStore.getState().isLoggedIn; const originalRequest = error.config; + const isLoggedIn = useAuthStore.getState().isLoggedIn; + + if (!isLoggedIn) { + return Promise.reject(error); + } if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; - if (!isLoggedIn) return; try { await ApiClient.auth.authControllerRefreshTokens(); - return ApiClient.instance(originalRequest); } catch (error) { return Promise.reject(error); diff --git a/examples/common_nestjs_remix/apps/web/app/api/generated-api.ts b/examples/common_nestjs_remix/apps/web/app/api/generated-api.ts index b542859..ecdb4e7 100644 --- a/examples/common_nestjs_remix/apps/web/app/api/generated-api.ts +++ b/examples/common_nestjs_remix/apps/web/app/api/generated-api.ts @@ -51,6 +51,15 @@ export type LogoutResponse = null; export type RefreshTokensResponse = null; +export interface CurrentUserResponse { + data: { + id: string; + createdAt: string; + updatedAt: string; + email: string; + }; +} + export interface GetUsersResponse { data: { id: string; @@ -88,7 +97,12 @@ export interface ChangePasswordBody { * @minLength 8 * @maxLength 64 */ - password: string; + newPassword: string; + /** + * @minLength 8 + * @maxLength 64 + */ + oldPassword: string; } export type ChangePasswordResponse = null; @@ -297,6 +311,20 @@ export class API extends HttpClient + this.request({ + path: `/auth/current-user`, + method: "GET", + format: "json", + ...params, + }), }; users = { /** diff --git a/examples/common_nestjs_remix/apps/web/app/api/mutations/useChangePassword.ts b/examples/common_nestjs_remix/apps/web/app/api/mutations/useChangePassword.ts new file mode 100644 index 0000000..393daf5 --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/api/mutations/useChangePassword.ts @@ -0,0 +1,34 @@ +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { toast } from "sonner"; +import { ApiClient } from "../api-client"; +import { ChangePasswordBody } from "../generated-api"; +import { useCurrentUserSuspense } from "../queries/useCurrentUser"; + +type ChangePasswordOptions = { + data: ChangePasswordBody; +}; + +export function useChangePassword() { + const { data: currentUser } = useCurrentUserSuspense(); + + return useMutation({ + mutationFn: async (options: ChangePasswordOptions) => { + const response = await ApiClient.users.usersControllerChangePassword( + currentUser.id, + options.data + ); + + return response.data; + }, + onSuccess: () => { + toast.success("Password updated successfully"); + }, + onError: (error) => { + if (error instanceof AxiosError) { + return toast.error(error.response?.data.message); + } + toast.error(error.message); + }, + }); +} diff --git a/examples/common_nestjs_remix/apps/web/app/api/mutations/useLoginUser.ts b/examples/common_nestjs_remix/apps/web/app/api/mutations/useLoginUser.ts index b29f8af..2e72f20 100644 --- a/examples/common_nestjs_remix/apps/web/app/api/mutations/useLoginUser.ts +++ b/examples/common_nestjs_remix/apps/web/app/api/mutations/useLoginUser.ts @@ -1,16 +1,17 @@ import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { toast } from "sonner"; +import { useAuthStore } from "~/modules/Auth/authStore"; import { ApiClient } from "../api-client"; import { LoginBody } from "../generated-api"; -import { useAuthStore } from "~/modules/Auth/authStore"; -import { toast } from "sonner"; -import { AxiosError } from "axios"; type LoginUserOptions = { data: LoginBody; }; export function useLoginUser() { - const { setLoggedIn } = useAuthStore(); + const setLoggedIn = useAuthStore((state) => state.setLoggedIn); + return useMutation({ mutationFn: async (options: LoginUserOptions) => { const response = await ApiClient.auth.authControllerLogin(options.data); diff --git a/examples/common_nestjs_remix/apps/web/app/api/mutations/useLogoutUser.ts b/examples/common_nestjs_remix/apps/web/app/api/mutations/useLogoutUser.ts index 624107c..0789f5e 100644 --- a/examples/common_nestjs_remix/apps/web/app/api/mutations/useLogoutUser.ts +++ b/examples/common_nestjs_remix/apps/web/app/api/mutations/useLogoutUser.ts @@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query"; import { ApiClient } from "../api-client"; import { toast } from "sonner"; import { AxiosError } from "axios"; +import { queryClient } from "../queryClient"; export function useLogoutUser() { const { setLoggedIn } = useAuthStore(); @@ -13,6 +14,7 @@ export function useLogoutUser() { return response.data; }, onSuccess: () => { + queryClient.clear(); setLoggedIn(false); }, onError: (error) => { diff --git a/examples/common_nestjs_remix/apps/web/app/api/mutations/useUpdateUser.ts b/examples/common_nestjs_remix/apps/web/app/api/mutations/useUpdateUser.ts new file mode 100644 index 0000000..6202f25 --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/api/mutations/useUpdateUser.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { toast } from "sonner"; +import { ApiClient } from "../api-client"; +import { UpdateUserBody } from "../generated-api"; +import { queryClient } from "../queryClient"; +import { + currentUserQueryOptions, + useCurrentUserSuspense, +} from "../queries/useCurrentUser"; + +type UpdateUserOptions = { + data: UpdateUserBody; +}; + +export function useUpdateUser() { + const { data: currentUser } = useCurrentUserSuspense(); + + return useMutation({ + mutationFn: async (options: UpdateUserOptions) => { + const response = await ApiClient.users.usersControllerUpdateUser( + currentUser.id, + options.data + ); + + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries(currentUserQueryOptions); + toast.success("User updated successfully"); + }, + onError: (error) => { + if (error instanceof AxiosError) { + return toast.error(error.response?.data.message); + } + toast.error(error.message); + }, + }); +} diff --git a/examples/common_nestjs_remix/apps/web/app/api/queries/useCurrentUser.ts b/examples/common_nestjs_remix/apps/web/app/api/queries/useCurrentUser.ts new file mode 100644 index 0000000..e58c83f --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/api/queries/useCurrentUser.ts @@ -0,0 +1,20 @@ +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; +import { ApiClient } from "../api-client"; + +export const currentUserQueryOptions = { + queryKey: ["currentUser"], + queryFn: async () => { + const response = await ApiClient.auth.authControllerCurrentUser(); + return response.data; + }, +}; + +export function useCurrentUser() { + const { data, ...rest } = useQuery(currentUserQueryOptions); + return { data: data?.data, ...rest }; +} + +export function useCurrentUserSuspense() { + const { data, ...rest } = useSuspenseQuery(currentUserQueryOptions); + return { data: data.data, ...rest }; +} diff --git a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Dashboard.layout.tsx b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Dashboard.layout.tsx index ab198c5..928d860 100644 --- a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Dashboard.layout.tsx +++ b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Dashboard.layout.tsx @@ -43,7 +43,10 @@ export default function DashboardLayout() { My Account - Settings + Dashboard + + + Settings Support diff --git a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.layout.tsx b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.layout.tsx new file mode 100644 index 0000000..0600efb --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.layout.tsx @@ -0,0 +1,39 @@ +import { Link, Outlet, useLocation } from "@remix-run/react"; +import { replace } from "lodash-es"; +import { cn } from "~/lib/utils"; + +export default function SettingsLayout() { + const { hash } = useLocation(); + const currentTab = replace(hash, "#", "") || "user-info"; + + const navigationItems = [ + { + id: "user-info", + title: "User Info", + }, + { + id: "change-password", + title: "Change Password", + }, + ]; + + return ( +
+ + +
+ ); +} diff --git a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.page.tsx b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.page.tsx new file mode 100644 index 0000000..791fb6f --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/Settings.page.tsx @@ -0,0 +1,11 @@ +import ChangePasswordForm from "./forms/ChangePasswordForm"; +import UserForm from "./forms/UserForm"; + +export default function SettingsPage() { + return ( +
+ + +
+ ); +} diff --git a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/ChangePasswordForm.tsx b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/ChangePasswordForm.tsx new file mode 100644 index 0000000..a57c50f --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/ChangePasswordForm.tsx @@ -0,0 +1,77 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { ChangePasswordBody } from "~/api/generated-api"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { useChangePassword } from "~/api/mutations/useChangePassword"; +import { cn } from "~/lib/utils"; + +const passwordSchema = z.object({ + oldPassword: z.string().min(8), + newPassword: z.string().min(8), +}); + +export default function ChangePasswordForm() { + const { mutate: changePassword } = useChangePassword(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(passwordSchema) }); + + const onSubmit = (data: ChangePasswordBody) => { + changePassword({ data }); + }; + + return ( + +
+ + Change password + Update your password here. + + + + + {errors.oldPassword && ( +

+ {errors.oldPassword.message} +

+ )} + + + {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ + + +
+
+ ); +} diff --git a/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/UserForm.tsx b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/UserForm.tsx new file mode 100644 index 0000000..a21b284 --- /dev/null +++ b/examples/common_nestjs_remix/apps/web/app/modules/Dashboard/Settings/forms/UserForm.tsx @@ -0,0 +1,64 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { UpdateUserBody } from "~/api/generated-api"; +import { useUpdateUser } from "~/api/mutations/useUpdateUser"; +import { useCurrentUserSuspense } from "~/api/queries/useCurrentUser"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { cn } from "~/lib/utils"; + +const updateUserSchema = z.object({ + email: z.string().email(), +}); + +export default function UserForm() { + const { mutate: updateUser } = useUpdateUser(); + const { data: currentUser } = useCurrentUserSuspense(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(updateUserSchema) }); + + const onSubmit = (data: UpdateUserBody) => { + updateUser({ data }); + }; + + return ( + +
+ + Change user information + Update your user information here. + + + + + {errors.email && ( +

{errors.email.message}

+ )} +
+ + + +
+
+ ); +} diff --git a/examples/common_nestjs_remix/apps/web/app/root.tsx b/examples/common_nestjs_remix/apps/web/app/root.tsx index d3b5c10..ab249bc 100644 --- a/examples/common_nestjs_remix/apps/web/app/root.tsx +++ b/examples/common_nestjs_remix/apps/web/app/root.tsx @@ -10,7 +10,7 @@ import { Toaster } from "./components/ui/sonner"; export function Layout({ children }: { children: React.ReactNode }) { return ( - + @@ -23,6 +23,8 @@ export function Layout({ children }: { children: React.ReactNode }) { { + route("", "modules/Dashboard/Settings/Settings.page.tsx", { + index: true, + }); + } + ); }); route("auth", "modules/Auth/Auth.layout.tsx", () => { route("login", "modules/Auth/Login.page.tsx"); diff --git a/examples/common_nestjs_remix/pnpm-lock.yaml b/examples/common_nestjs_remix/pnpm-lock.yaml index bc8ea00..40b34a0 100644 --- a/examples/common_nestjs_remix/pnpm-lock.yaml +++ b/examples/common_nestjs_remix/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: isbot: specifier: ^4.1.0 version: 4.1.0 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 lucide-react: specifier: ^0.408.0 version: 0.408.0(react@18.3.1) @@ -297,6 +300,9 @@ importers: '@types/express': specifier: ^4.17.20 version: 4.17.20 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/morgan': specifier: ^1.9.9 version: 1.9.9 @@ -3566,6 +3572,12 @@ packages: dependencies: '@types/node': 20.14.10 + /@types/lodash-es@4.17.12: + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + dependencies: + '@types/lodash': 4.17.6 + dev: true + /@types/lodash@4.17.6: resolution: {integrity: sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==} dev: true @@ -8579,6 +8591,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + /lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} dev: true