Skip to content

Commit

Permalink
Merge pull request #9 from Selleo/jw/user-management
Browse files Browse the repository at this point in the history
feat: user management view
  • Loading branch information
typeWolffo authored Jul 24, 2024
2 parents 59d4443 + 1566cfe commit 0f02e10
Show file tree
Hide file tree
Showing 21 changed files with 498 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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")
Expand All @@ -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=")) {
Expand All @@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Res,
UnauthorizedException,
UseGuards,
Get,
} from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Static } from "@sinclair/typebox";
Expand All @@ -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 {
Expand Down Expand Up @@ -97,4 +99,16 @@ export class AuthController {

return null;
}

@Get("current-user")
@Validate({
response: baseResponse(commonUserSchema),
})
async currentUser(
@CurrentUser() currentUser: { userId: string },
): Promise<BaseResponse<Static<typeof commonUserSchema>>> {
const account = await this.authService.currentUser(currentUser.userId);

return new BaseResponse(account);
}
}
12 changes: 11 additions & 1 deletion examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down
48 changes: 48 additions & 0 deletions examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 5 additions & 3 deletions examples/common_nestjs_remix/apps/web/app/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 29 additions & 1 deletion examples/common_nestjs_remix/apps/web/app/api/generated-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -297,6 +311,20 @@ export class API<SecurityDataType extends unknown> extends HttpClient<SecurityDa
format: "json",
...params,
}),

/**
* No description
*
* @name AuthControllerCurrentUser
* @request GET:/auth/current-user
*/
authControllerCurrentUser: (params: RequestParams = {}) =>
this.request<CurrentUserResponse, any>({
path: `/auth/current-user`,
method: "GET",
format: "json",
...params,
}),
};
users = {
/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
});
}
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -13,6 +14,7 @@ export function useLogoutUser() {
return response.data;
},
onSuccess: () => {
queryClient.clear();
setLoggedIn(false);
},
onError: (error) => {
Expand Down
Loading

0 comments on commit 0f02e10

Please sign in to comment.