diff --git a/package-lock.json b/package-lock.json index fc26f41..d45ab46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", "lodash.debounce": "^4.0.8", "next": "14.2.25", "react": "^18", @@ -24,6 +25,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", "@types/react": "^18", @@ -630,6 +632,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -3583,6 +3592,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index aa169c1..dcb52b8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", "lodash.debounce": "^4.0.8", "next": "14.2.25", "react": "^18", @@ -25,6 +26,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", "@types/react": "^18", diff --git a/src/app/(after-login)/mypage/_components/BackButton.tsx b/src/app/(after-login)/mypage/_components/BackButton.tsx index 599777a..ef391e9 100644 --- a/src/app/(after-login)/mypage/_components/BackButton.tsx +++ b/src/app/(after-login)/mypage/_components/BackButton.tsx @@ -1,8 +1,7 @@ "use client"; - import { useRouter } from "next/navigation"; import Image from "next/image"; -import BackIcon from "../../../../../public/icon/arrow_right_icon.svg"; +import BackIcon from "public/icon/arrow_right_icon.svg"; export default function BackButton() { const router = useRouter(); diff --git a/src/app/(after-login)/mypage/_components/PasswordSection.tsx b/src/app/(after-login)/mypage/_components/PasswordSection.tsx index 2b12ad1..dd328c2 100644 --- a/src/app/(after-login)/mypage/_components/PasswordSection.tsx +++ b/src/app/(after-login)/mypage/_components/PasswordSection.tsx @@ -1,25 +1,122 @@ "use client"; - +import Cookies from "js-cookie"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { updatePassword } from "@/lib/apis/accountApi"; +import { useAlertStore } from "@/lib/store/useAlertStore"; import Button from "@/components/common/button/Button"; import Input from "@/components/common/input/Input"; +const passwordSchema = z + .object({ + password: z.string().min(1, "현재 비밀번호를 입력해주세요."), + newPassword: z.string().min(8, "새 비밀번호는 8자 이상이어야 합니다."), + confirmNewPassword: z.string().min(1, "새 비밀번호를 다시 입력해주세요."), + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: "새 비밀번호가 일치하지 않습니다.", + path: ["confirmNewPassword"], + }); + +type PasswordFormData = z.infer; + export default function PasswordSection() { + const accessToken = Cookies.get("accessToken") ?? ""; + const { openAlert } = useAlertStore(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + watch, + reset, + } = useForm({ + resolver: zodResolver(passwordSchema), + mode: "onBlur", + defaultValues: { + password: "", + newPassword: "", + confirmNewPassword: "", + }, + }); + + const currentPassword = watch("password"); + const currentNewPassword = watch("newPassword"); + + const onSubmit = async (formData: PasswordFormData) => { + try { + await updatePassword({ + token: accessToken, + password: formData.password, + newPassword: formData.newPassword, + }); + openAlert("profileUpdateSuccess"); + reset(); + } catch (error) { + if (error instanceof Error) { + const errorInfo = JSON.parse(error.message); + if (errorInfo.status === 401) { + openAlert("wrongPassword"); + } else { + openAlert("profileUpdateFailed"); + } + } else { + openAlert("profileUpdateFailed"); + } + } + }; + + const isFormChanged = currentPassword !== "" || currentNewPassword !== ""; + return ( -
+
비밀번호 변경
- {/* 로그인/회원가입 페이지에서 한 것 처럼 패스워드 토글 버튼 넣어도 좋을 것 같네요 */} - - - + + +
- +
-
+ ); } diff --git a/src/app/(after-login)/mypage/_components/ProfileSection.tsx b/src/app/(after-login)/mypage/_components/ProfileSection.tsx index 048b91d..d6f374e 100644 --- a/src/app/(after-login)/mypage/_components/ProfileSection.tsx +++ b/src/app/(after-login)/mypage/_components/ProfileSection.tsx @@ -1,56 +1,153 @@ "use client"; - import { useEffect, useState } from "react"; +import Cookies from "js-cookie"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { UserInfo } from "@/lib/types"; -import { fetchUser } from "@/lib/apis/usersApi"; +import { + fetchUser, + updateProfile, + uploadProfileImage, +} from "@/lib/apis/accountApi"; +import { useAlertStore } from "@/lib/store/useAlertStore"; import Button from "@/components/common/button/Button"; import ImageInput from "@/components/common/input/ImageInput"; import Input from "@/components/common/input/Input"; +const profileSchema = z.object({ + nickname: z + .string() + .min(1, "닉네임을 입력해주세요.") + .max(10, "닉네임은 10자 이하로 작성해주세요."), +}); + +type ProfileFormData = z.infer; + export default function ProfileSection() { const [data, setData] = useState(null); - const accessToken = localStorage.getItem("accessToken") ?? ""; + const [loading, setLoading] = useState(true); + const [profileImageUrl, setProfileImageUrl] = useState(null); + const [imageError, setImageError] = useState(null); + const [initialData, setInitialData] = useState(null); + const accessToken = Cookies.get("accessToken") ?? ""; + const { openAlert } = useAlertStore(); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + setValue, + watch, + } = useForm({ + resolver: zodResolver(profileSchema), + mode: "onBlur", + }); + + const currentNickname = watch("nickname"); useEffect(() => { const getData = async () => { - const res = await fetchUser({ - token: accessToken, - }); - setData(res); + setLoading(true); + try { + const res = await fetchUser({ token: accessToken }); + setData(res); + setInitialData(res); + setValue("nickname", res.nickname); + setProfileImageUrl(res.profileImageUrl); + } catch { + openAlert("userNotFound"); + } finally { + setLoading(false); + } }; + if (accessToken) { + getData(); + } else { + setLoading(false); + } + }, [accessToken, setValue, openAlert]); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; - getData(); - }, []); + setImageError(null); + try { + const res = await uploadProfileImage({ token: accessToken, image: file }); + setProfileImageUrl(res.profileImageUrl); + } catch (error) { + if (error instanceof Error) { + if (error.message.includes("Failed to fetch")) { + setImageError("이미지 파일의 용량이 5MB를 넘습니다."); + } else { + setImageError(error.message); + } + } else { + setImageError("이미지 업로드에 실패했습니다."); + } + } + }; - if (!data) return; + const onSubmit = async (formData: ProfileFormData) => { + try { + const updatedData = await updateProfile({ + token: accessToken, + nickname: formData.nickname, + profileImageUrl: profileImageUrl ?? undefined, + }); + setData(updatedData); + openAlert("profileUpdateSuccess"); + } catch { + openAlert("profileUpdateFailed"); + } + }; - const { email, nickname, profileImageUrl } = data; + const isFormChanged = + (currentNickname && currentNickname !== initialData?.nickname) || + (profileImageUrl && profileImageUrl !== initialData?.profileImageUrl); - // 이 밑으로 formData(닉네임, 이미지) state 생성하시고, 기본값으로 각각 위의 nickname, profileImageUrl 넣어주시면 될 거예요 - // 아래는 vercel 배포를 위해 위 값들을 임시로 사용한 테스트 코드라 나중에 삭제해주시면 됩니다 - console.log(nickname); - console.log(profileImageUrl); + if (loading) return

로딩 중...

; + if (!data) return

정보를 불러오지 못했습니다.

; return ( -
+
프로필
-
- +
+ + {imageError &&

{imageError}

}
- {/* 이메일 인풋에는 나중에 disabled 속성 들어가야 합니다 */} - - + +
- +
-
+
); } diff --git a/src/components/common/alert/AlertProvider.tsx b/src/components/common/alert/AlertProvider.tsx index 8a27103..fbd37ce 100644 --- a/src/components/common/alert/AlertProvider.tsx +++ b/src/components/common/alert/AlertProvider.tsx @@ -1,5 +1,5 @@ "use client"; - +import Cookies from "js-cookie"; import { useRouter } from "next/navigation"; import { useAlertStore } from "@/lib/store/useAlertStore"; import { useColumnStore } from "@/lib/store/useColumnStore"; @@ -11,14 +11,14 @@ export default function AlertProvider() { const { currentAlert } = useAlertStore(); const { selectedColumnId } = useColumnStore(); const router = useRouter(); - const accessToken = localStorage.getItem("accessToken") ?? ""; + const accessToken = Cookies.get("accessToken") ?? ""; const handleDeleteClick = async () => { - deleteColumn({ + if (!accessToken || !selectedColumnId) return; + await deleteColumn({ token: accessToken, columnId: Number(selectedColumnId), }); - router.refresh(); }; @@ -37,6 +37,10 @@ export default function AlertProvider() { )} {currentAlert === "userNotFound" && } {currentAlert === "wrongPassword" && } + {currentAlert === "profileUpdateSuccess" && ( + window.location.reload()} /> + )} + {currentAlert === "profileUpdateFailed" && } ); } diff --git a/src/components/common/alert/alertData.ts b/src/components/common/alert/alertData.ts index 804a2b5..cbd3e9f 100644 --- a/src/components/common/alert/alertData.ts +++ b/src/components/common/alert/alertData.ts @@ -6,4 +6,6 @@ export const alertMessages: Record = { loginSuccess: "로그인 되었습니다.", userNotFound: "존재하지 않는 유저입니다.", wrongPassword: "현재 비밀번호가 틀렸습니다.", + profileUpdateSuccess: "정상적으로 수정되었습니다.", + profileUpdateFailed: "정보 수정에 실패했습니다.", }; diff --git a/src/components/common/input/ImageInput.tsx b/src/components/common/input/ImageInput.tsx index d1fd5de..740e612 100644 --- a/src/components/common/input/ImageInput.tsx +++ b/src/components/common/input/ImageInput.tsx @@ -1,13 +1,15 @@ "use client"; -import { ChangeEvent, useState } from "react"; +import { ChangeEvent, useState, useEffect } from "react"; import Image from "next/image"; import { twMerge } from "tailwind-merge"; import clsx from "clsx"; import { postImage } from "@/lib/apis/imageApi"; -interface BaseImageInputProps { +interface BaseImageInputProps + extends React.InputHTMLAttributes { label?: string; variant: "task" | "profile"; + initialImageUrl?: string | null; } interface TaskImageInputProps extends BaseImageInputProps { @@ -21,10 +23,21 @@ interface ProfileImageInputProps extends BaseImageInputProps { type ImageInputProps = TaskImageInputProps | ProfileImageInputProps; -const ImageInput = ({ label, variant, ...props }: ImageInputProps) => { - const [uploadImgUrl, setUploadImgUrl] = useState(""); +const ImageInput = ({ + label, + variant, + initialImageUrl, + ...props +}: ImageInputProps) => { + const [uploadImgUrl, setUploadImgUrl] = useState( + initialImageUrl || "" + ); const [error, setError] = useState(null); + useEffect(() => { + setUploadImgUrl(initialImageUrl || ""); + }, [initialImageUrl]); + const handleFileChange = async (e: ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -39,6 +52,11 @@ const ImageInput = ({ label, variant, ...props }: ImageInputProps) => { setUploadImgUrl(localUrl); setError(null); + if (props.onChange) { + props.onChange(e); + return; + } + try { const columnId = variant === "task" @@ -88,6 +106,7 @@ const ImageInput = ({ label, variant, ...props }: ImageInputProps) => { fill sizes={sizesValue} className="object-cover rounded-md" + priority /> ) : (
@@ -97,6 +116,7 @@ const ImageInput = ({ label, variant, ...props }: ImageInputProps) => { fill sizes="24px" className="object-contain" + priority />
)} @@ -108,6 +128,7 @@ const ImageInput = ({ label, variant, ...props }: ImageInputProps) => { accept="image/*" onChange={handleFileChange} className="hidden" + {...props} /> {error &&

{error}

}
diff --git a/src/components/layout/navbar/UserMenu.tsx b/src/components/layout/navbar/UserMenu.tsx index 16c605e..d283326 100644 --- a/src/components/layout/navbar/UserMenu.tsx +++ b/src/components/layout/navbar/UserMenu.tsx @@ -1,5 +1,5 @@ "use client"; - +import Cookies from "js-cookie"; import { useEffect, useState } from "react"; import { UserInfo } from "@/lib/types"; import { useRouter } from "next/navigation"; @@ -14,22 +14,23 @@ export default function UserMenu() { const [isOpen, setIsOpen] = useState(false); const isMobile = useIsMobile(); const router = useRouter(); - const accessToken = localStorage.getItem("accessToken") ?? ""; + const accessToken = Cookies.get("accessToken") ?? ""; const setDashboardId = useDashboardStore((state) => state.setDashboardId); useEffect(() => { const getData = async () => { - const res = await fetchUser({ - token: accessToken, - }); - setData(res); + if (accessToken) { + const res = await fetchUser({ + token: accessToken, + }); + setData(res); + } }; - getData(); - }, []); + }, [accessToken]); - if (!data) return; + if (!data) return null; const { nickname, profileImageUrl } = data; @@ -39,6 +40,7 @@ export default function UserMenu() { }; const handleLogout = () => { + Cookies.remove("accessToken"); localStorage.removeItem("accessToken"); document.cookie = "accessToken=; path=/; max-age=0"; router.push(ROUTE.HOME); diff --git a/src/components/layout/sidebar/SideMenuList.tsx b/src/components/layout/sidebar/SideMenuList.tsx index 2357692..44fa695 100644 --- a/src/components/layout/sidebar/SideMenuList.tsx +++ b/src/components/layout/sidebar/SideMenuList.tsx @@ -1,4 +1,6 @@ +"use client"; import { useEffect, useState, useRef } from "react"; +import Cookies from "js-cookie"; import { useIntersection } from "@/lib/hooks/useIntersection"; import { DashboardList } from "@/lib/types"; import { fetchDashboardList } from "@/lib/apis/dashboardsApi"; @@ -11,20 +13,18 @@ export default function SideMenuList() { const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [isLast, setIsLast] = useState(false); + const accessToken = Cookies.get("accessToken") ?? ""; const observerRef = useRef(null); - const accessToken = localStorage.getItem("accessToken") ?? ""; const handleLoad = async () => { - if (isLoading || isLast) return; + if (isLoading || isLast || !accessToken) return; setIsLoading(true); - try { const { dashboards: newDashboards } = await fetchDashboardList({ token: accessToken, size: PAGE_SIZE, page, }); - if (newDashboards.length === 0) { setIsLast(true); } else { @@ -37,8 +37,8 @@ export default function SideMenuList() { }; useEffect(() => { - handleLoad(); - }, []); + if (accessToken) handleLoad(); + }, [accessToken]); useIntersection({ target: observerRef, diff --git a/src/lib/apis/accountApi.ts b/src/lib/apis/accountApi.ts new file mode 100644 index 0000000..f2b4366 --- /dev/null +++ b/src/lib/apis/accountApi.ts @@ -0,0 +1,109 @@ +import { UserInfo } from "@/lib/types"; +import { BASE_URL } from "@/lib/constants/urls"; +import { postImage } from "@/lib/apis/imageApi"; + +type FetchOptions = { + token: string; + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: B; + isMultipart?: boolean; +}; + +async function fetchAPI( + endpoint: string, + options: FetchOptions +): Promise { + const { token, method = "GET", body, isMultipart = false } = options; + + const headers: HeadersInit = { + Authorization: `Bearer ${token}`, + }; + + if (!isMultipart) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(`${BASE_URL}${endpoint}`, { + method, + headers, + body: isMultipart + ? (body as FormData) + : body + ? JSON.stringify(body) + : undefined, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + JSON.stringify({ status: response.status, message: errorData.message }) + ); + } + + if (response.status === 204) { + return undefined as R; + } + + return response.json() as R; +} + +export async function fetchUser({ + token, +}: { + token: string; +}): Promise { + return fetchAPI("/users/me", { token }); +} + +export async function updateProfile({ + token, + nickname, + profileImageUrl, +}: { + token: string; + nickname?: string; + profileImageUrl?: string; +}): Promise { + const body: { nickname?: string; profileImageUrl?: string } = {}; + if (nickname !== undefined) body.nickname = nickname; + if (profileImageUrl !== undefined) body.profileImageUrl = profileImageUrl; + + return fetchAPI<{ nickname?: string; profileImageUrl?: string }, UserInfo>( + "/users/me", + { + token, + method: "PUT", + body, + } + ); +} + +export async function updatePassword({ + token, + password, + newPassword, +}: { + token: string; + password: string; + newPassword: string; +}): Promise { + return fetchAPI<{ password: string; newPassword: string }, void>( + "/auth/password", + { + token, + method: "PUT", + body: { password, newPassword }, + } + ); +} + +export async function uploadProfileImage({ + token, + image, +}: { + token: string; + image: File; +}): Promise<{ profileImageUrl: string }> { + const profileImageUrl = await postImage("profile", undefined, image, token); + return { profileImageUrl }; +} diff --git a/src/lib/apis/imageApi.ts b/src/lib/apis/imageApi.ts index 5477c96..7af2a28 100644 --- a/src/lib/apis/imageApi.ts +++ b/src/lib/apis/imageApi.ts @@ -3,7 +3,8 @@ import { BASE_URL } from "@/lib/constants/urls"; export const postImage = async ( variant: "task" | "profile", columnId?: number, - file?: File + file?: File, + token?: string ): Promise => { if (!file) throw new Error("파일이 없습니다."); if (variant === "task" && columnId === undefined) { @@ -21,7 +22,7 @@ export const postImage = async ( const response = await fetch(url, { method: "POST", headers: { - Authorization: `Bearer ${process.env.NEXT_PUBLIC_API_TOKEN}`, + Authorization: `Bearer ${token || process.env.NEXT_PUBLIC_API_TOKEN}`, }, body: formData, }); diff --git a/src/lib/store/useAlertStore.ts b/src/lib/store/useAlertStore.ts index 3a6bb39..d34f36d 100644 --- a/src/lib/store/useAlertStore.ts +++ b/src/lib/store/useAlertStore.ts @@ -8,6 +8,8 @@ export type AlertKey = | "loginSuccess" | "userNotFound" | "wrongPassword" + | "profileUpdateSuccess" + | "profileUpdateFailed" | null; type AlertState = {