안녕하세요?
지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 3편을 계속 이어 나가도록 하겠습니다.
1편 : https://cpro95.tistory.com/463
2편 : https://cpro95.tistory.com/465
2편까지 우리는 로그인, 로그아웃 로직까지 구현했었는데요.
3편에서는 유저 정보 업데이트와 패스워드 변경에 대해 알아 보겠습니다.
일단 계정 설정 페이지를 만들기 전에 첫 화면(/)에서 "계정보기" 와 "계정설정" 링크를 추가하도록 하겠습니다.
pages/index.js -- 일부 수정
import React from "react";
import Link from "next/link";
import { useCurrentUser } from "@/hooks/index";
export default function IndexPage() {
const [user, { mutate }] = useCurrentUser();
const handleLogout = async () => {
await fetch("/api/auth", {
method: "DELETE",
});
mutate(null);
};
return (
<div className="px-4 py-5 my-5 text-center">
<h1 className="display-5 fw-bold">
{user ? user.name : "stranger"} 님 반갑습니다.
</h1>
{user ? (
<div className="d-grid gap-2 d-sm-flex justify-content-sm-center">
<Link href={`/user/${user._id}`}>
<a className="btn btn-info btn px-4 gap-3">계정보기</a>
</Link>
<Link href="/settings">
<a className="btn btn-warning btn px-4 gap-3">계정설정</a>
</Link>
</div>
) : (
<></>
)}
<div className="col-lg-6 mx-auto">
<p className="lead mt-4 mb-4 fw-normal">
NextJS와 MongoDB를 이용한 로그인 세션 구현 샘플입니다.
<br />
계정이 있으시면 아래 로그인 버튼을 누르시고,
<br />
없으시면 가입하기 버튼을 눌러 계정을 만드십시요.
</p>
<div className="d-grid gap-2 d-sm-flex justify-content-sm-center">
{user ? (
<a
className="btn btn-primary btn px-4 gap-3"
onClick={handleLogout}
>
로그아웃
</a>
) : (
<button type="button" className="btn btn-primary btn px-4 gap-3">
<Link href="/login">
<a>로그인</a>
</Link>
</button>
)}
<button type="button" className="btn btn-outline-secondary btn px-4">
<Link href="/signup">
<a>가입하기</a>
</Link>
</button>
</div>
</div>
</div>
);
}
계정보기는 /user/${user._id} 라우팅 방식을 쓰고 계정설정은 /settings 라우팅을 쓰고 있습니다.
먼저 계정설정 부분을 볼까요?
pages/settings.js
import React, { useState, useEffect, useRef } from "react";
import Head from "next/head";
import Router from "next/router";
import { useCurrentUser } from "@/hooks/index";
const ProfileSection = () => {
const [user, { mutate }] = useCurrentUser();
const [isUpdating, setIsUpdating] = useState(false);
const nameRef = useRef();
const profilePictureRef = useRef();
const [msg, setMsg] = useState({ message: "", isError: false });
const [msg2, setMsg2] = useState({ message: "", isError: false });
useEffect(() => {
nameRef.current.value = user.name;
}, [user]);
const handleSubmit = async (event) => {
event.preventDefault();
if (isUpdating) return;
setIsUpdating(true);
const formData = new FormData();
if (profilePictureRef.current.files[0]) {
formData.append("profilePicture", profilePictureRef.current.files[0]);
}
formData.append("name", nameRef.current.value);
const res = await fetch("/api/user", {
method: "PATCH",
body: formData,
});
if (res.status === 200) {
const userData = await res.json();
mutate({
user: {
...user,
...userData.user,
},
});
setMsg({ message: "Profile updated" });
} else {
setMsg({ message: await res.text(), isError: true });
}
setIsUpdating(false);
};
const handleSubmitPasswordChange = async (e) => {
e.preventDefault();
if (
e.currentTarget.newPassword.value !== e.currentTarget.newPassword2.value
) {
setMsg2({ message: "New password do not match each other" });
e.currentTarget.oldPassword.value = "";
e.currentTarget.newPassword.value = "";
e.currentTarget.newPassword2.value = "";
return;
} else {
const body = {
oldPassword: e.currentTarget.oldPassword.value,
newPassword: e.currentTarget.newPassword.value,
newPassword2: e.currentTarget.newPassword2.value,
};
e.currentTarget.oldPassword.value = "";
e.currentTarget.newPassword.value = "";
e.currentTarget.newPassword2.value = "";
const res = await fetch("/api/user/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.status === 200) {
setMsg2({ message: "Password updated" });
} else {
setMsg2({ message: await res.text(), isError: true });
}
}
};
return (
<>
<Head>
<title>Settings</title>
</Head>
<button
type="button"
className="btn btn-light px-4"
onClick={() => Router.replace("/")}
>
홈으로
</button>
<section className="px-4 py-5 mb-2 text-center">
<h1 className="display-5 fw-bold mb-5">계정 설정</h1>
<div className="col-lg-6 mx-auto">
<form onSubmit={handleSubmit}>
{msg.message ? (
<p
style={{
color: msg.isError ? "red" : "#0070f3",
textAlign: "center",
}}
>
{msg.message}
</p>
) : null}
<div className="input-group mb-2">
<label className="input-group-text">이름</label>
<input
id="name"
name="name"
type="text"
className="form-control"
ref={nameRef}
/>
</div>
<div className="input-group mb-2">
<label className="btn btn-outline-secondary disabled">
사진 업로드
</label>
<input
id="avatar"
type="file"
className="form-control"
accept="image/png, image/jpeg"
ref={profilePictureRef}
/>
</div>
<button className="w-100 btn btn-lg btn-primary mb-2" type="submit">
수정하기
</button>
</form>
</div>
</section>
<section>
<h1 className="display-5 fw-bold mb-2 text-center">패스워드 재설정</h1>
<form onSubmit={handleSubmitPasswordChange}>
{msg2.message ? (
<p
style={{
color: msg.isError ? "red" : "#0070f3",
textAlign: "center",
}}
>
{msg2.message}
</p>
) : null}
<div className="px-4 py-5 my-2 text-center">
<div className="col-lg-6 mx-auto">
<div className="form-floating mb-2">
<input
id="oldpassword"
name="oldPassword"
type="password"
className="form-control"
placeholder="비밀번호"
required
/>
<label forhtml="oldpassword">비밀번호</label>
</div>
<div className="form-floating mb-2">
<input
id="newpassword"
name="newPassword"
type="password"
className="form-control"
placeholder="새 비밀번호"
required
/>
<label forhtml="newpassword">새 비밀번호</label>
</div>
<div className="form-floating mb-2">
<input
id="newpassword2"
name="newPassword2"
type="password"
className="form-control"
placeholder="새 비밀번호 한번 더"
required
/>
<label forhtml="newpassword2">새 비밀번호 한번 더</label>
</div>
<button
className="w-100 btn btn-lg btn-danger mb-2"
type="submit"
>
패스워드 변경하기
</button>
</div>
</div>
</form>
</section>
</>
);
};
const SettingPage = () => {
const [user] = useCurrentUser();
if (!user) {
return (
<>
<h1 className="text-center my-5">Please sign in</h1>
<button
type="button"
className="w-100 btn btn-lg btn-secondary px-4 gap-3"
onClick={() => Router.replace("/login")}
>
로그인하기
</button>
</>
);
} else {
return (
<>
<ProfileSection />
</>
);
}
};
export default SettingPage;
계정설정 로직은 조금 복잡한데요. 차근차근 하나씩 살펴 보도록 하겠습니다.
먼저 UI를 살펴 보겠습니다.
왼쪽 상단에는 홈으로 가는 버튼을 만들었고
계정설정에서 이름과 프로파일 사진을 올릴수 있는 input 란을 만들었습니다.
패스워드 재설정에는 익숙한 패스워드 관련 input들이 보이고 있습니다.
먼저, 계정설정 이름과 사진을 바꾸는 로직을 보기 전에 우리가 가입해야할 서비스가 있습니다.
바로 인터넷 상에서 자신의 이미지를 저장시켜 주고 API로 불러올 수도 있게 해주는 무료 서비스인데요.
바로 cloudinary 란 서비스입니다.
무료 계정을 지원하고 있습니다. 무료 계정은 이미지를 1Gb까지 저장할 수 있어 취미로 코딩하는 분들한테는 딱이죠.
가입하고 Dashboard 부분에 가보면 API 관련 Key 를 얻을 수 있습니다.
이제 관련 API Key를 .env 파일에 추가하도록 합시다.
우리가 필요로하는 거는 바로 위 사진 마지막에 있는 API Environment variable 입니다.
옆에 copy to clipboard를 눌러서 클립보드에 복사한 후 편집기에서 붙혀넣기 하시면 됩니다.
일단 여기까지 했다고 보고 다음으로 진행토록 하겠습니다.
계정설정은 단순하게 이름과 사진을 업로드하는 로직입니다.
handleSubmit 함수가 그일을 하고 있는데요.
formData를 만들고 그 안에 profilePicture 까지 같이 넣어서 /api/user로 PATCH 방식으로 전송합니다.
좀 있다가 바로 /api/user 부분 핸들링을 살펴보도록 하겠습니다.
그러면 두번째 패스워드 변경 부분인데요.
계정설정과 비슷합니다.
handleSubmitPasswordChange 란 함수가 그일을 하고 있으며, 간단하게 패스워드 부분만 인수로 받아서 /api/user/password 쪽으로PUT 라우팅하고 있습니다.
좀 있다가 이쪽 라우팅도 알아 보겠습니다.
여기서 좀 더 깊게 살펴볼게 HTML 의 input 태그를 통해 이미지도 업로드할 수 있는데요.
바로 input 태그 속성에 accept 란 속성을 사용하면 됩니다.
accept="image/png, image/jpeg"
위 코드는 png, jpeg 파일만 허용한다는 뜻입니다.
위와 같이 input 태그로 이미지를 업로드하면 그 이미지의 경로는 바로 profilePictureRef 의 current 변수의 files 배열에 있는데요.
그 배열 첫번째 값이 바로 우리가 업로드할 주소입니다.
즉, profilePictureRef.current.files[0] 라고 사용하시면 됩니다.
이제 계정설정 화면을 봤으니까 상세 api 라우팅 부분을 보도록 하겠습니다.
먼저, /api/user 부분입니다.
pages/api/user/index.js
import nc from "next-connect";
import multer from "multer";
import { v2 as cloudinary } from "cloudinary";
import { all } from "@/middlewares/index";
import { updateUserById } from "@/db/index";
import { extractUser } from "@/lib/api-helpers";
const upload = multer({ dest: "/tmp" });
const handler = nc();
/* eslint-disable camelcase */
const {
hostname: cloud_name,
username: api_key,
password: api_secret,
} = new URL(process.env.CLOUDINARY_URL);
cloudinary.config({
cloud_name,
api_key,
api_secret,
});
handler.use(all);
handler.get(async (req, res) => {
// Filter out password
if (!req.user) return res.json({ user: null });
const { password, ...u } = req.user;
res.json({ user: u });
});
handler.patch(upload.single("profilePicture"), async (req, res) => {
if (!req.user) {
req.status(401).end();
return;
}
let profilePicture;
if (req.file) {
const image = await cloudinary.uploader.upload(req.file.path, {
width: 512,
height: 512,
crop: "fill",
});
profilePicture = image.secure_url;
}
const { name } = req.body;
const user = await updateUserById(req.db, req.user._id, {
...(name && { name }),
...(profilePicture && { profilePicture }),
});
res.json({ user: extractUser(user) });
});
export const config = {
api: {
bodyParser: false,
},
};
export default handler;
위 코드는 2편에서 본 코드에서 계정설정 부분이 추가된 코드입니다.
Cloudinary 관련 부분이 있고, handler.patch 부분처럼 이름과 사진을 처리하는 코드도 있습니다.
이미지를 Cloudinary 에 업로드 하기 위해 우리는 multer 란 패키지와 cloudinary 패키지를 쓰고 있습니다.
그리고 db 유틸인 updateUserById란 함수를 통해 이름과 사진을 등록하고 있습니다.
pages/api/user/password.js
이제 패스워드 재설정 부분입니다.
import nc from 'next-connect';
import bcrypt from 'bcryptjs';
import { all } from '@/middlewares/index';
import { updateUserById } from '@/db/index';
const handler = nc();
handler.use(all);
handler.put(async (req, res) => {
if (!req.user) { res.json(401).send('you need to be authenticated'); return; }
const { oldPassword, newPassword } = req.body;
if (!(await bcrypt.compare(oldPassword, req.user.password))) {
res.status(401).send('The password you has entered is incorrect.');
return;
}
const password = await bcrypt.hash(newPassword, 10);
await updateUserById(req.db, req.user._id, { password });
res.end('ok');
});
export default handler;
로직은 handler.put으로 라우팅 처리하고 있으며 bcrypt로 해쉬한 비밀번호를 updateUserById를 이용해 재설정하고 하고 있습니다.
지금까지 계정설정에 대해 알아 보았는데요.
이제는 계정 보기 부분에 대해 알아 보겠습니다.
지금은 가입한 계정이 하나지만 여러명이 가입한 사이트가 되면 유저이름도 그만큼 많을건데요.
이때 유저 각각의 정보를 볼려면 NextJS의 다이내믹 라우팅을 이용해야 합니다.
우리는 여기서 /pages/user/[userId] 라고 사용할 예정입니다.
pages/user/[userId]/index.js
import React from "react";
import Head from "next/head";
import Link from "next/link";
import Error from "next/error";
import { all } from "@/middlewares/index";
import { useCurrentUser } from "@/hooks/index";
import { extractUser } from "@/lib/api-helpers";
import { findUserById } from "@/db/index";
import { defaultProfilePicture } from "@/lib/default";
export default function UserPage({ user }) {
if (!user) return <Error statusCode={404} />;
const { name, email, profilePicture, _id } = user || {};
const [currentUser] = useCurrentUser();
const isCurrentUser = currentUser?._id === user._id;
return (
<>
<Head>
<title>{name}</title>
</Head>
<div className="container m-5">
{isCurrentUser && (
<div>
<Link href="/">
<button className="w-25 btn btn-primary mb-2 mx-2" type="button">
홈으로{" "}
</button>
</Link>
<Link href="/settings">
<button className="w-25 btn btn-info mb-2" type="button">
계정 설정
</button>
</Link>
</div>
)}
<div className="p-4">
<h3 className="mb-5">Profile</h3>
<p className="mb-3">Name : {name}</p>
<p className="mb-3">Email : {email}</p>
</div>
<div className="">
<img
src={profilePicture || defaultProfilePicture}
width="256"
height="256"
alt={name}
/>
</div>
</div>
</>
);
}
export async function getServerSideProps(context) {
await all.run(context.req, context.res);
const user = extractUser(
await findUserById(context.req.db, context.params.userId)
);
if (!user) context.res.statusCode = 404;
return { props: { user } };
}
이 파일은 [userId] 처럼 스퀘어 브라킷으로 시작하는데요. 이렇게 시작하는 라우팅은 NextJS에서 다이내믹 라우팅이라고 부릅니다.
즉, NextJS의 가장 큰 장점인 getServerSideProps 함수를 사용할 수 있다는 뜻인데요.
서버사이드쪽 작업을 웹상에서 할 수 있는 신기술인거죠.
NextJS 의 getServerSideProps 기술이 나오기 전에는 유저 정보를 클라이언트에 보여줄려면 무조건 백엔드 서버를 구축해야했었습니다.
ExpressJS를 이용한 서버 구축이 필요했었는데요.
요새는 NextJS의 이 같은 기술로 Serverless 앱을 손쉽게 구축할수 있게 되었습니다.
그러면, 마저 코드를 살펴보겠습니다.
[userId]처럼 스퀘어 브라킷으로 시작하면 그 안에 있는 이름 즉 userId가 바로 getServerSideProps에서 전달되는 변수 즉, 여기서는 코드에 보이듯이 context가 되고 그 context 객체의 params 변수에 저장됩니다.
그래서 getServerSideProps 함수에서 서버사이드 렌더링에 필요한 유저 정보를 findUserById 함수로 얻고 그걸 Props로 해서 React 컴포넌트에 전달해 주고 React 컴포넌트에서 화면에 뿌려주게 됩니다.
UI 부분은 참 간단합니다.
name과 email, profilePicture 부분을 화면에 그냥 뿌려주게 됩니다.
그럼, 여기까지 어떻게 라우팅해서 오게 되나면 바로 앞에서 살펴보았던 /user/${user._id} 부분입니다.
계정보기 링크가 위와 같이 사용되고 있고 mongodb에 user._id 부분을 다이내믹 라우팅으로 처리하고 있는겁니다.
지금까지 계정보기와 계정설정에 대해 살펴보았습니다.
다음으로는 계정권한 설정에 대해 알아 보겠습니다.
pages/userlist.js
import React, { useState, useEffect } from "react";
import Head from "next/head";
import Router from "next/router";
import Error from "next/error";
import { all } from "@/middlewares/index";
import { useCurrentUser } from "@/hooks/index";
import { findUserAll } from "@/db/index";
export default function UserListPage({ users }) {
const [isUpdating, setIsUpdating] = useState(false);
const [user] = useCurrentUser();
const [msg, setMsg] = useState({ message: "", isError: false });
if (!users) return <Error statusCode={404} />;
async function handleSubmit({ _id, admin }) {
if (isUpdating) return;
setIsUpdating(true);
const body = {
_id: _id,
admin: !admin,
};
const res = await fetch("/api/user/admin", {
method: "PATCH",
body: JSON.stringify(body),
});
if (res.status === 200) {
setMsg({ message: "Admin updated" });
} else {
setMsg({ message: await res.text(), isError: true });
}
setIsUpdating(false);
Router.reload(window.location.pathname);
}
if (!user) {
return (
<>
<Head>
<title>UserList</title>
</Head>
<button
type="button"
className="btn btn-light px-4"
onClick={() => Router.replace("/")}
>
홈으로
</button>
<h1 className="text-center my-5">Please sign in</h1>
<button
type="button"
className="w-100 btn btn-lg btn-secondary px-4 gap-3"
onClick={() => Router.replace("/login")}
>
로그인하기
</button>
</>
);
} else if (!user.admin) {
return (
<>
<Head>
<title>UserList</title>
</Head>
<button
type="button"
className="btn btn-light px-4"
onClick={() => Router.replace("/")}
>
홈으로
</button>
<h1 className="text-center my-5">
You are not admin, Please sign in as admin
</h1>
<button
type="button"
className="w-100 btn btn-lg btn-secondary px-4 gap-3"
onClick={() => Router.replace("/login")}
>
로그인하기
</button>
</>
);
} else {
return (
<>
<Head>
<title>UserList</title>
</Head>
<button
type="button"
className="btn btn-light px-4"
onClick={() => Router.replace("/")}
>
홈으로
</button>
<div className="container mt-4">
<h1>Total Users : {users.length} </h1>
{msg.message ? (
<p
style={{
color: msg.isError ? "red" : "#0070f3",
textAlign: "center",
}}
>
{msg.message}
</p>
) : null}
<table className="table">
<thead className="thead-dark">
<tr>
<th scope="col">id</th>
<th scope="col">Email</th>
<th scope="col">Admin</th>
<th scope="col">Toggle</th>
</tr>
</thead>
<tbody>
{users.map((user2) => (
<tr key={user2._id}>
<th scope="row">id: {user2._id}</th>
<td>{user2.email}</td>
<td>{user2.admin ? "O" : "X"}</td>
<td>
<button
className="btn btn-info mx-2"
onClick={(e) => {
e.preventDefault();
handleSubmit(user2);
}}
>
토글
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
}
export async function getServerSideProps(context) {
await all.run(context.req, context.res);
const users = await findUserAll(context.req.db);
if (!users) context.res.statusCode = 404;
return { props: { users } };
}
먼저 pages 폴더 밑의 userlist.js 파일입니다.
우리의 사용자보기 링크는 /userlist 라우팅이 되는 거죠.
UI 부분을 먼저 보시면 로그인한 계정이 admin 계정이 아니면 아래와 같이 나옵니다.
당연히 로그인 안한 상태일 경우 홈으로 보내버리고요.
그러면, admin 계정일때는 어떻게 보일까요?
가입되어 있는 유저 리스트가 보이고 마지막 칸에 토글 버튼을 적용시켜서 admin 상태로 변하게 할 수 있습니다.
그러면, 처음에 가입했을때는 모두 admin 상태가 false로 되어 있는데 본인 계정을 어떻게 false 로 바꿀 수 있을까요?
여러가지 방법이 있습니다.
먼저, Strapi 같이 처음 가입한 유저는 admin으로 정하는 방식이 있고요.
이 방법은 유저 가입코드에 findUserAll 함수로 유저리스트를 받아 왔는데 아무것도 없다면 admin을 true로 설정하는 방식입니다.
pages/api/users.js 파일에 보면 유저 가입 로직이 있는데,이 부분에서 findUserAll로 기존 유저가 있는지 없는지 체크한 다음 만약 없다면 admin : true 항목만 추가하시면 됩니다.
두번째 방법은 admin 계정은 어차피 서버 관리자이기 때문에 mongodb atlas 에서 직접 admin 을 true로 고치면 됩니다.
세번째 방법은 userlist.js파일에서 !user.admin 부분을 일시적으로 없애서 위와 같이 토글할 수 있게 한다음 본인 계정만 토글하고 나서 다시 !user.admin 부분을 원래대로 복원하는 방법이 있습니다.
저는 두번째 방법을 썼는데 편하신데로 하시면 됩니다.
그럼, admin 토글하는 db쪽 로직을 볼까요?
/api/user/admin 으로 PATCH 방식으로 라우팅했습니다.
라우팅할때 body 쪽에 _id와 admin 을 토글하는 방식으로 정보를 전달하고 있습니다.
const body = {
_id: _id,
admin: !admin,
};
이제 라우팅 부분인 api/user/admin.js 파일을 보도록 하겠습니다.
pages/api/user/admin.js
import nc from "next-connect";
import { all } from "@/middlewares/index";
import { updateUserById } from "@/db/index";
const handler = nc();
handler.use(all);
handler.patch(async (req, res) => {
if (!req.user) {
req.status(401).end();
return;
}
const { _id, admin } = JSON.parse(req.body);
await updateUserById(req.db, _id, { admin });
res.end("ok");
});
export default handler;
코드는 간단합니다. req.body 부분을 parse 해서 _id와 admin 만 뽑고 그걸 다시 updateUserById 함수로 전달해서 mongo db를 업데이트합니다.
그리고 홈(/)에서 admin 일 경우 /userlist 라우팅 링크가 보이도록 pages/index.js 파일을 수정하도록 하겠습니다.
{user ? (
<div className="d-grid gap-2 d-sm-flex justify-content-sm-center">
<Link href={`/user/${user._id}`}>
<a className="btn btn-info btn px-4 gap-3">계정보기</a>
</Link>
<Link href="/settings">
<a className="btn btn-warning btn px-4 gap-3">계정설정</a>
</Link>
{user.admin ? (
<Link href="/userlist">
<a className="btn btn-success btn px-4 gap-3">사용자보기</a>
</Link>
) : (
<></>
)}
</div>
) : (
<></>
)}
위에서 보시면 간단하게 user.admin 이 true일 경우 보이는 사용자보기 링크 하나를 추가했습니다.
아래 사진처럼 이제 로그인한 유저가 admin 일 경우 사용자보기 링크가 나타날 겁니다.
지금까지 NextJS와 MongoDB 를 이용해서 서버 구축없이 유저 로그인 세션 만들기에 도전해 봤는데요.
UI 부분은 본인이 잘하시는 Material-UI 나 TailwindCSS, ChakraUI 등 여러가지를 적용해서 직접 만들어 보시는 것도 좋을 듯 싶습니다.
지금까지 읽어 주셔서 감사합니다.
'코딩 > React' 카테고리의 다른 글
NextJS와 MongoDB 4편 Chakra-UI 적용하기 (0) | 2021.09.15 |
---|---|
NextJS Data 가져오는 방법 (SSG, SSR, ISR) (3) | 2021.09.12 |
NextJS와 MongoDB로 유저 로그인 세션 구현하기 2편 (12) | 2021.08.22 |
NextJS에서 환경변수 .env 사용하는 방법 (2) | 2021.08.22 |
NextJS와 MongoDB로 유저 로그인 세션 구현하기 1편 (2) | 2021.08.22 |