Skip to content

Commit 832747e

Browse files
authored
[Feat] 모바일 관리자 페이지 구현 (#166)
* Fix: profile 페이지 settings 페이지로 통합 * Feat: 모바일 레이아웃 구성 * Feat: settings페이지 레이아웃 구성 * Feat: useUserQuery 연동 * Feat: 비밀번호 변경 로직 추가 * Feat: 비밀번호 변경로직 에러처리 추가 * Feat: 비밀번호 변경 로직 커스텀훅 분리 * Feat: settings 페이지 로그아웃 기능 구현 * Feat: teams 컴포넌트 추가 * Feat: 프로필 사진 변경 구현 * Feat: useProfileImage 커스텀로직 분리 * Chore: CSS 수정 * Feat: settings페이지 모달 연결 * Merge: merge conflict 해결
1 parent fbe7c20 commit 832747e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+559
-250
lines changed

apps/api/src/controllers/userController.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ export const updateProfileImage = async (req: UpdateProfileImageRequest, res: Re
551551
const profileImageUrl = (req.file as Express.MulterS3.File).location;
552552

553553
if (!userId) {
554-
res.status(400).send({ message: "인증 토큰이 유효하지 않습니다." });
554+
res.status(401).send({ message: "인증 토큰이 유효하지 않습니다." });
555555
return;
556556
}
557557

@@ -560,15 +560,8 @@ export const updateProfileImage = async (req: UpdateProfileImageRequest, res: Re
560560
return;
561561
}
562562

563-
const user = await User.findById(userId);
564-
565-
if (!user) {
566-
res.status(404).send({ message: "사용자를 찾을 수 없습니다." });
567-
return;
568-
}
569-
570-
await User.findByIdAndUpdate(userId, { profileImage: profileImageUrl });
571-
res.status(200).send({ message: "프로필 사진이 변경되었습니다." });
563+
const user = await User.findByIdAndUpdate(userId, { profileImage: profileImageUrl });
564+
res.status(200).send({ message: "프로필 사진이 변경되었습니다.", user });
572565
};
573566

574567
interface UpdateUserCredentialsRequest extends Request {
@@ -585,8 +578,8 @@ interface UpdateUserCredentialsRequest extends Request {
585578
* patch:
586579
* tags:
587580
* - Users
588-
* summary: 사용자 비밀번호 업데이트
589-
* description: 현재 사용자의 비밀번호를 변경합니다.
581+
* summary: 사용자 비밀번호 변경
582+
* description: 현재 사용자의 비밀번호를 새 비밀번호로 변경합니다.
590583
* requestBody:
591584
* required: true
592585
* content:
@@ -597,9 +590,11 @@ interface UpdateUserCredentialsRequest extends Request {
597590
* currentPassword:
598591
* type: string
599592
* description: 현재 비밀번호
593+
* example: currentPassword123
600594
* newPassword:
601595
* type: string
602596
* description: 새 비밀번호
597+
* example: newPassword456
603598
* responses:
604599
* 200:
605600
* description: 비밀번호가 성공적으로 변경되었습니다.
@@ -612,25 +607,33 @@ interface UpdateUserCredentialsRequest extends Request {
612607
* type: string
613608
* example: 비밀번호가 변경되었습니다.
614609
* 400:
615-
* description: 필수 정보가 누락되었습니다.
610+
* description: 요청이 잘못되었습니다. (비밀번호 누락 또는 기존 비밀번호와 동일)
616611
* content:
617612
* application/json:
618613
* schema:
619614
* type: object
620615
* properties:
621616
* message:
622617
* type: string
623-
* example: 필수 정보가 누락되었습니다.
618+
* examples:
619+
* missingPassword:
620+
* value: 비밀번호를 입력해 주세요.
621+
* samePassword:
622+
* value: 기존의 비밀번호와 동일합니다.
624623
* 401:
625-
* description: 비밀번호가 일치하지 않습니다.
624+
* description: 인증 오류 또는 비밀번호 불일치
626625
* content:
627626
* application/json:
628627
* schema:
629628
* type: object
630629
* properties:
631630
* message:
632631
* type: string
633-
* example: 비밀번호가 일치하지 않습니다.
632+
* examples:
633+
* invalidToken:
634+
* value: 인증 토큰이 유효하지 않습니다.
635+
* passwordMismatch:
636+
* value: 비밀번호가 일치하지 않습니다.
634637
* 404:
635638
* description: 사용자를 찾을 수 없습니다.
636639
* content:
@@ -647,12 +650,12 @@ export const updateUserCredentials = async (req: UpdateUserCredentialsRequest, r
647650
const { currentPassword, newPassword } = req.body;
648651

649652
if (!userId) {
650-
res.status(400).send({ message: "인증 토큰이 유효하지 않습니다." });
653+
res.status(401).send({ message: "인증 토큰이 유효하지 않습니다." });
651654
return;
652655
}
653656

654657
if (!currentPassword || !newPassword) {
655-
res.status(400).send({ message: "필수 정보가 누락되었습니다." });
658+
res.status(400).send({ message: "비밀번호를 입력해 주세요." });
656659
return;
657660
}
658661

@@ -669,6 +672,10 @@ export const updateUserCredentials = async (req: UpdateUserCredentialsRequest, r
669672
return;
670673
}
671674

675+
if (currentPassword === newPassword) {
676+
res.status(400).send({ message: "기존의 비밀번호와 동일합니다." });
677+
}
678+
672679
user.password = newPassword;
673680
await user.save();
674681

apps/api/src/routes/userRoutes.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const userRouter: Router = Router();
1616

1717
// 유저 정렬
1818
userRouter.get("/", asyncHandler(getUsers));
19-
userRouter.get("/user", asyncHandler(authenticateToken), asyncHandler(getUser));
19+
userRouter.get("/me", asyncHandler(authenticateToken), asyncHandler(getUser));
2020

2121
// 유저 정보 변경
2222
// TODO : admin middleware 추가
@@ -31,9 +31,14 @@ userRouter.delete("/:userId", asyncHandler(deleteUser));
3131
userRouter.post("/create", upload.single("profileImage"), asyncHandler(createUser));
3232

3333
// 내 사진 변경
34-
userRouter.patch("/me/image", upload.single("profileImage"), asyncHandler(updateProfileImage));
34+
userRouter.patch(
35+
"/me/image",
36+
asyncHandler(authenticateToken),
37+
upload.single("profileImage"),
38+
asyncHandler(updateProfileImage),
39+
);
3540

3641
// 비밀변호 변경
37-
userRouter.patch("/me/password", asyncHandler(updateUserCredentials));
42+
userRouter.patch("/me/password", asyncHandler(authenticateToken), asyncHandler(updateUserCredentials));
3843

3944
export default userRouter;

apps/web/api/teams.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,39 @@
11
import { API_ENDPOINTS } from "@repo/constants";
2-
import { type ResponseType, type ITeam, type TeamType } from "@repo/types";
2+
import { type ResponseType, type ITeam, type TeamType, type MessageResponse } from "@repo/types";
33
import { axiosRequester } from "@/lib/axios";
44

55
export const postCreateTeam = async (teamName: ITeam): Promise<ResponseType<ITeam>> => {
6-
const { data } = await axiosRequester({
6+
const { data } = await axiosRequester<ResponseType<ITeam>>({
77
options: {
88
method: "POST",
99
url: API_ENDPOINTS.TEAMS.CREATE_TEAM,
1010
data: teamName,
1111
},
1212
});
1313

14-
return data as ResponseType<ITeam>;
14+
return data;
1515
};
1616

1717
export const getTeams = async (): Promise<TeamType[]> => {
18-
const { data } = await axiosRequester({
18+
const { data } = await axiosRequester<TeamType[]>({
1919
options: {
2020
method: "GET",
2121
url: API_ENDPOINTS.TEAMS.GET_ALL,
2222
},
2323
});
2424

25-
return data as TeamType[];
25+
return data;
2626
};
2727

28-
interface MessageResponse {
29-
message: string;
30-
}
31-
3228
export const deleteTeam = async (teamId: string): Promise<MessageResponse> => {
33-
const { data } = await axiosRequester({
29+
const { data } = await axiosRequester<MessageResponse>({
3430
options: {
3531
method: "DELETE",
3632
url: API_ENDPOINTS.TEAMS.DELETE_TEAM(teamId),
3733
},
3834
});
3935

40-
return data as MessageResponse;
36+
return data;
4137
};
4238

4339
interface UpdateRequest {
@@ -46,15 +42,15 @@ interface UpdateRequest {
4642
}
4743

4844
export const updateTeamName = async ({ teamId, newName }: UpdateRequest): Promise<MessageResponse> => {
49-
const { data } = await axiosRequester({
45+
const { data } = await axiosRequester<MessageResponse>({
5046
options: {
5147
method: "PUT",
5248
url: API_ENDPOINTS.TEAMS.UPDATE_TEAM_NAME(teamId),
5349
data: { name: newName },
5450
},
5551
});
5652

57-
return data as MessageResponse;
53+
return data;
5854
};
5955

6056
export const updateTeamOrder = async (updatedTeams: TeamType[]): Promise<ResponseType<TeamType[]>> => {

apps/web/api/users.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { API_ENDPOINTS } from "@repo/constants";
2-
import { type IUser } from "@repo/types";
2+
import { type ChangePasswordPayload, type MessageResponse, type IUser, type ResponseType } from "@repo/types";
33
import { axiosRequester } from "@/lib/axios";
44

55
export const getUser = async (): Promise<IUser> => {
@@ -12,3 +12,30 @@ export const getUser = async (): Promise<IUser> => {
1212

1313
return data;
1414
};
15+
16+
export const patchUserPassword = async (payload: ChangePasswordPayload): Promise<MessageResponse> => {
17+
const { data } = await axiosRequester<MessageResponse>({
18+
options: {
19+
method: "PATCH",
20+
url: API_ENDPOINTS.USERS.ME_PASSWORD,
21+
data: payload,
22+
},
23+
});
24+
25+
return data;
26+
};
27+
28+
export const patchUserImage = async (formData: FormData): Promise<ResponseType<IUser>> => {
29+
const { data } = await axiosRequester<ResponseType<IUser>>({
30+
options: {
31+
method: "PATCH",
32+
headers: {
33+
"Content-Type": "multipart/form-data",
34+
},
35+
url: API_ENDPOINTS.USERS.ME_IMAGE,
36+
data: formData,
37+
},
38+
});
39+
40+
return data;
41+
};

apps/web/app/_hooks/useSignInMutation.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
22
import { type FieldValues } from "react-hook-form";
3-
import { setCookie } from "cookies-next";
43
import { type AxiosError } from "axios";
54
import { type SignInResponseType } from "@repo/types/src/responseType";
65
import { useRouter } from "next/navigation";
@@ -9,9 +8,13 @@ import { notify } from "@/app/store/useToastStore";
98
import { postSignIn } from "@/api/auth";
109
import { useAuthStore } from "@/src/stores/useAuthStore";
1110

11+
interface MessageResponse {
12+
message: string;
13+
}
14+
1215
export const useSignInMutation = (): UseMutationResult<
1316
SignInResponseType<string>,
14-
AxiosError<{ message?: string }>,
17+
AxiosError<MessageResponse>,
1518
FieldValues
1619
> => {
1720
const router = useRouter();
@@ -21,25 +24,18 @@ export const useSignInMutation = (): UseMutationResult<
2124
return useMutation({
2225
mutationFn: (payload: FieldValues) => postSignIn(payload),
2326
onSuccess: (res) => {
24-
// accessToken을 client cookie에 저장
25-
setCookie("accessToken", res.accessToken);
26-
// user 정보 캐싱
27-
queryClient.setQueryData(["userResponse"], res.user);
28-
// localStorage 및 store에 저장
29-
if (res.user) {
30-
login(res.user);
27+
const { user, accessToken, message } = res;
28+
if (accessToken && user) {
29+
login(user, accessToken);
30+
queryClient.setQueryData(["userResponse"], user);
31+
notify("success", message);
32+
setTimeout(() => {
33+
router.replace(PAGE_NAME.DASHBOARD);
34+
}, 1000);
3135
}
32-
33-
// 피드백 토스트
34-
if (typeof res.message === "string") notify("success", res.message);
35-
36-
setTimeout(() => {
37-
router.replace(PAGE_NAME.DASHBOARD);
38-
}, 1000);
3936
},
4037
onError: (error) => {
41-
const err = error as AxiosError<{ message: string }>;
42-
const errMessage = err.response?.data.message;
38+
const errMessage = error.response?.data.message;
4339
if (errMessage) notify("error", errMessage);
4440
},
4541
});

apps/web/app/admin/members/_components/sidepanel/ProfileImageUploader.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import { IMAGE_CONFIG } from "@repo/constants";
77

88
interface ProfileImageUploaderProps {
99
currentImage: FormImageType;
10-
onImageChange: (file: ImageFileType) => void;
10+
onImageChange?: (file: ImageFileType) => void;
11+
size?: "sm" | "md";
1112
}
1213

13-
export default function ProfileImageUploader({ currentImage, onImageChange }: ProfileImageUploaderProps): JSX.Element {
14-
const [imageObjectUrl, setImageObjectUrl] = useState<string>("");
14+
export default function ProfileImageUploader({
15+
currentImage,
16+
onImageChange,
17+
size = "md",
18+
}: ProfileImageUploaderProps): JSX.Element {
19+
const [imageObjectUrl, setImageObjectUrl] = useState("");
1520
const [isImageError, setIsImageError] = useState(false);
1621

1722
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>): void => {
@@ -20,7 +25,7 @@ export default function ProfileImageUploader({ currentImage, onImageChange }: Pr
2025

2126
const newObjectUrl = URL.createObjectURL(file);
2227
setImageObjectUrl(newObjectUrl);
23-
onImageChange(file);
28+
onImageChange?.(file);
2429
};
2530

2631
const getImageSource = (): DisplayImageType => {
@@ -45,23 +50,21 @@ export default function ProfileImageUploader({ currentImage, onImageChange }: Pr
4550

4651
useEffect(() => {
4752
return () => {
48-
if (imageObjectUrl) {
49-
URL.revokeObjectURL(imageObjectUrl);
50-
}
53+
if (imageObjectUrl) URL.revokeObjectURL(imageObjectUrl);
5154
};
5255
}, [imageObjectUrl]);
5356

5457
return (
55-
<div className="mb-[262px] flex items-center gap-24">
58+
<div className="flex items-center gap-16 md:gap-24">
5659
<Image
5760
src={getImageSource()}
5861
alt={currentImage ? MEMBER_FORM_MESSAGES.IMAGE.PREVIEW_ALT : MEMBER_FORM_MESSAGES.IMAGE.DEFAULT_ALT}
59-
width={120}
60-
height={120}
62+
width={size === "sm" ? 72 : 120}
63+
height={size === "sm" ? 72 : 120}
6164
placeholder="blur"
6265
blurDataURL={IMAGE_CONFIG.BLUR_DATA_URL}
6366
onError={handleError}
64-
className="size-120 rounded-full object-cover"
67+
className={`rounded-full object-cover ${size === "sm" ? "size-72" : "size-120"}`}
6568
/>
6669
<label
6770
htmlFor="profileImage"

0 commit comments

Comments
 (0)