Twilio Setup
우선 twilio를 들어가기 전에 코드를 리팩토링 좀 하고 들어가겠습니다.
/pages/api/enter.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
interface reqDataType {
email?: string;
phone?: string;
}
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ResponseType>
) => {
const { email, phone }: reqDataType = req.body;
const user = phone
? { phone: +phone }
: email
? { email }
: null;
if (!user) return res.status(400).json({ ok: false });
const payload = Math.floor(10000 + Math.random() * 90000) + "";
const token = await client.token.create({
data: {
payload,
user: {
connectOrCreate: {
where: {
...user,
},
create: {
name: "Anonymous",
...user,
},
},
},
},
});
res.status(200).json({ ok: true });
};
export default withHandler("POST", handler);
다음과 같이 기존의 payload를 user로 바꾸었습니다. 그리고 payload에다가 랜덤 6자리 (숫자로 이루어진)문자열로 만들어 주었습니다.
그리고 res의 타입을 지정해 주었습니다. 또한 ResponseType은 나중에 많이 사용할것 이므로 withHandler에 적어놓고 import해 오도록 하겠습니다.
/libs/server/withHandler.ts
import type {
NextApiRequest,
NextApiResponse,
NextApiHandler,
} from "next/types";
type MethodType = "GET" | "POST" | "DELETE";
export interface ResponseType {
ok: boolean;
[key: string]: any;
}
type HandlerType = {
(
method: MethodType,
handler: NextApiHandler<ResponseType>
): NextApiHandler;
};
const withHandler: HandlerType = (method, handler) => {
return async function (req, res) {
if (req.method !== method) {
res.status(405).end();
}
try {
await handler(req, res);
} catch (error) {
console.error(error);
if (error instanceof Error) {
res.status(500).json({ error: error.message });
} else {
res.status(500).json({ error: "Internal Server Error" });
}
}
};
};
export default withHandler;
이제 twiilio계정을 생성하고 먼저 환경변수를 설정해 주어야 합니다. 여기에 구체적인 값은 쓸 수 없으므로, 어떻게 하는지만 서술하자면. 그냥 SID값과, token값을 .env파일에 각각 TWILIO_SID, TWILIO_TOKEN의 이름으로 집어 넣어 줍니다. 그리고 Messaging -> Services로 가서 새 프로젝트를 만들고 Messaging -> Get Set Up으로 들어 간다음 임의의 trial phone number를 발급 받은 다음에, Send an SMS로 들어가서 내 번호로 한번 텍스트를 보내보도록 하겠습니다.
이 상태에서 보내게 되면 Body Text에 해당하는 메시지가 휴대폰으로 도착하게 됩니다.
Sending SMS, Email
이제 이메일과 sms로 token정보를 지정한 전화번호와, 이메일로 보내보도록 하겠습니다.
/libs/api/users/enter.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import twilio from "twilio";
import mail from "@sendgrid/mail";
mail.setApiKey(process.env.SENDGRID_API_KEY!);
const twilioClient = twilio(
process.env.TWILIO_SID,
process.env.TWILIO_TOKEN
);
interface reqDataType {
email?: string;
phone?: string;
}
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ResponseType>
) => {
const { email, phone }: reqDataType = req.body;
const user = phone
? { phone: +phone }
: email
? { email }
: null;
if (!user) return res.status(400).json({ ok: false });
const payload = Math.floor(10000 + Math.random() * 90000) + "";
const token = await client.token.create({
data: {
payload,
user: {
connectOrCreate: {
where: {
...user,
},
create: {
name: "Anonymous",
...user,
},
},
},
},
});
if (phone) {
const message = await twilioClient.messages.create({
messagingServiceSid: process.env.TWILIO_MSID,
// 원래라면 phone으로 보내야 하지만 -> dev process에서는 그냥 내 폰으로
to: process.env.MY_PHONE!,
body: `Your login token is ${payload}`,
});
console.log(message);
} else if (email) {
const email = await mail.send({
from: "heart20021010@gmail.com",
to: "dhdbswl021010@naver.com",
subject: "Your Carrot Market Verification Email",
text: `Your token is ${payload}`,
html: `<strong>Your token is ${payload}</strong>`,
});
console.log(email);
}
res.status(200).json({ ok: true });
};
export default withHandler("POST", handler);
환경변수로는 twilio의 service sid, 국가 코드가 포함된 phone number, SENDGRID의 api key를 추가 해주었습니다. sendgrid는 거래 및 마케팅 이메일을 위한 고객 커뮤니케이션 플랫폼입니다. 이는 twilio안에 속해있는 것으로서, 이메일을 보낼 때 편리합니다. (외에도 mailgun과 같은거 사용해도 좋습니다. )
정상 작동하는 것을 보실 수 있습니다.
Token UI
이제 본격적으로 token을 입력받을 수 있는 UI를 만들어 보도록 하겠습니다. 이는 기존의 enter page에서 몇몇의 div와 form을 없애고 새로운 div와 form으로 대치하는데에서 시작할 겁니다. 그리고 원래 만들었던 useMutation을 활용하여 token용을 하나 만들어 주고 활용해 주고 api route에서 전용 api를 하나 만들어 주면 끝납니다.
또한 useMutation가 반환하는 data의 타입을 제네릭을 사용해서 추론하기로 했습니다. 그래야 타입 추론이 뭔가 더 깔끔하게 이루어지기 때문이죠
/pages/enter.tsx
import { useState } from "react";
import type { NextPage } from "next";
import useMutation from "@libs/client/useMutation";
import { cls } from "@libs/client/utils";
import Button from "@components/button";
import Input from "@components/Input";
import { useForm } from "react-hook-form";
import { ConversationList } from "twilio/lib/rest/conversations/v1/service/conversation";
type MethodType = "email" | "phone";
interface EnterForm {
email?: string;
phone?: string;
}
interface TokenForm {
token: string;
}
interface EnterMutationResult {
ok: boolean;
}
interface MutationResult {
ok: boolean;
}
const Enter: NextPage = () => {
const [enter, { loading, data, error }] =
useMutation<EnterMutationResult>("/api/users/enter");
const [
confirmToken,
{
loading: tokenLoading,
data: tokenData,
error: tokenError,
},
] = useMutation<MutationResult>("/api/users/confirm");
const [submitting, setSubmitting] = useState(false);
const { register, handleSubmit, reset } = useForm<EnterForm>();
const {
register: tokenRegister,
handleSubmit: tokenHandleSubmit,
} = useForm<TokenForm>();
const [method, setMethod] = useState<MethodType>("email");
const onEmailClick = () => {
reset();
setMethod("email");
};
const onPhoneClick = () => {
reset();
setMethod("phone");
};
const onValid = (validForm: EnterForm) => {
enter(validForm);
};
const onTokenValid = (validForm: TokenForm) => {
if (tokenLoading) return;
confirmToken(validForm);
};
return (
<div className="mt-16 px-4">
<h3 className="text-center text-3xl font-bold">
Enter to Carrot
</h3>
<div className="mt-8">
{data?.ok ? (
<form
onSubmit={tokenHandleSubmit(onTokenValid)}
className="mt-8 flex flex-col"
>
<Input
register={tokenRegister("token", {
required: true,
})}
name="token"
label="Confirmation Token"
type="number"
required
/>
<Button
text={tokenLoading ? "Loading" : "Confirm Token"}
/>
</form>
) : (
<>
<div className="flex flex-col items-center">
<h5 className="text-sm font-medium text-gray-500">
Enter using:
</h5>
<div className="mt-8 grid w-full grid-cols-2 gap-16 border-b">
<button
className={cls(
"border-b-2 pb-4 font-medium",
method === "email"
? " border-orange-500 text-orange-500"
: "border-transparent text-gray-500"
)}
onClick={onEmailClick}
>
Email
</button>
<button
className={cls(
"border-b-2 pb-4 font-medium",
method === "phone"
? " border-orange-500 text-orange-500"
: "border-transparent text-gray-500"
)}
onClick={onPhoneClick}
>
Phone
</button>
</div>
</div>
<form
onSubmit={handleSubmit(onValid)}
className="mt-8 flex flex-col"
>
{method === "email" ? (
<Input
register={register("email", {
required: true,
})}
name="email"
label="Email address"
type="email"
/>
) : null}
{method === "phone" ? (
<Input
register={register("phone", {
required: true,
})}
name="phone"
label="Phone number"
type="number"
kind="phone"
/>
) : null}
{method === "email" ? (
<Button
text={loading ? "Loading" : "Get login link"}
/>
) : null}
{method === "phone" ? (
<Button
text={
loading ? "Loading" : "Get one-time password"
}
/>
) : null}
</form>
</>
)}
<div className="mt-8">
<div className="relative">
<div className="absolute w-full border-t border-gray-300" />
<div className="relative -top-3 text-center ">
<span className="bg-white px-2 text-sm text-gray-500">
Or enter with
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<button className="flex justify-center rounded-md border border-gray-500 bg-white py-2 px-4 text-sm font-medium text-gray-500 shadow-sm hover:bg-gray-50">
<svg
className="h-5 w-5"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
</svg>
</button>
<button className="flex justify-center rounded-md border border-gray-500 bg-white py-2 px-4 text-sm font-medium text-gray-500 shadow-sm hover:bg-gray-50">
<svg
className="h-5 w-5"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default Enter;
/pages/api/users/confirm.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const { token } = req.body;
console.log(token);
res.status(200).end();
};
export default withHandler("POST", handler);
간단하게 token을 받을 수 있는 form을 만들어 보았고, 이를 처리해 줄 api route또한 기존의 setting들을 참고해서 쉽게 작성해 보았습니다.
다음과 같이 입력한 token이 백엔드에서 잘 보여지는 것을 볼 수 있습니다.
이 외에도 prisma의 schema를 수정해 주었습니다. 바로 prisma studio를 활용하여 User를 삭제하려고 했더니, 의존하는 token이 있어서 막아놓았더라고요. 이를 검색했더니 onDelete속성을 Cascade로 바꾸어 주면 된다고 합니다. 이는 user를 삭제하면 이와 연관되어 있는 모든 record를 삭제 해달라는 것 이였습니다. 이외에도
```
Cascade
Restrict
NoAction
SetNull
SetDefault
```
https://www.prisma.io/docs/concepts/components/prisma-schema/relations/referential-actions
다양한 기능들을 가지고 있는 속성들이 많았습니다.
Serverless Session
이번에는 session을 이용해서 token과 관련된 유저의 id를 저장하는 작업을 진행해 보도록 하겠습니다.
이를 위해서 저희는 stateless, serverless session인 iron session을 사용하도록 하겠습니다.
이의 동작 원리는 다음과 같습니다.
{ key: value } -----> encrypt -----> asdfasdfasdf
asdfasdfasdf -----> decrypt -----> { key: value }
이는 JWT와는 엄청난 차이점이 있습니다. 뭐 둘다 암호화하고 복호화하는건 비슷하게 보일지라도, JWT는 아무 사용자나 그 안의 json데이터를 볼 수 있습니다. 그저 sign을 두어서 그 sign을 검증하는 작업을 통해서 json데이터를 신뢰하고 사용하는 것 뿐입니다. 이러한 점에서 JWT는 다른 사람들이 봐도 되는 정보를 두는 것이 맞습니다.
반면에 iron session은 아예 json데이터를 암호화 해서 보내서 아무도 못보게 만들어 버립니다. 그냥 server에서만 이를 확인할 수 있도록 한 것입니다. 중요한 정보를 이 안에다 넣는 것이 맞습니다.
session과 cookie와의 차이점은 많이 알아보았지만, 다시 짚고 가보자면
cookie는 브라우저에 key: value쌍으로 정보를 집어 넣어서 서버의 모든 요청마다 이 쿠키를 요청 헤더에 넣어서 같이 보내는 것을 말합니다.
반면에 session은 client단에 cookie로 서버에서 식별할 수 있는 id나 필요한 데이터만 두고 서버에 요청이 오면 이 cookie를 확인해서 서버에서 값을 가져오는 것을 말합니다.
cookie에 비해 session이 서버의 부하가 크지만 보안적 측면에서 cookie가 변조/탈취 될 가능성이 더 크다.
/pages/api/users/confirm.tsx
import { withIronSessionApiRoute } from "iron-session/next";
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const { token } = req.body;
const exists = await client.token.findUnique({
where: {
payload: token,
},
include: {
user: true,
},
});
if (!exists) res.status(404).end();
req.session.user = {
id: exists?.userId,
};
await req.session.save();
res.status(200).end();
};
export default withIronSessionApiRoute(
withHandler("POST", handler),
{
cookieName: "carrotsession",
password:
"2039847509283745098273409587asdfasdfasdfasdfasdfasdfasdf",
}
);
다음과 같이 iron-session의 helper function을 이용해서 handler함수 안에서 req.session에 접근가능할 수 있게 해 주었습니다. 만약 token에 해당하는 user가 있어야지만 session에 이에 해당하는 userId값을 넣어 주는 작업을 진행했습니다. 그리고 cookie의 이름은 "carrotsession"이고 이를 암호화하기 위한 암호는 임의로 그냥 32자리 이상의 문자열을 넣어 주었습니다. 한번 그럼 결과를 확인해 봅시다.
Clean Coding
이제 코드를 좀 더 예쁘게 정리를 하고, redirection하는 부분까지 구현을 해 봅시다.
우선 iron-session을 위한 helper function의 타입 선언과, 옵션을 매번 적어주는 것이 까다롭기 그지 없었습니다. 그래서 이를 withSession HOC로 한번 빼보도록 하겠습니다.
/libs/server/withSession.ts
import { withIronSessionApiRoute } from "iron-session/next";
declare module "iron-session" {
interface IronSessionData {
user?: {
id: number;
};
}
}
const cookieOptions = {
cookieName: "carrotsession",
password: process.env.COOKIE_PASSWORD!,
};
export function withApiSession(fn: any) {
return withIronSessionApiRoute(fn, cookieOptions);
}
다음과 같이 매번 HOC로 감싸줘야 합니다. 그 이유는 Serverless 플랫폼이기 때문에 매번 서버에 접속해서 session에 접근해야 하기 때문입니다. 한번만 서버에 접속한다고 끝날 일이 아닙니다.
/pagse/api/users/confirm.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const { token } = req.body;
const foundToken = await client.token.findUnique({
where: {
payload: token,
},
include: {
user: true,
},
});
if (!foundToken) return res.status(404).end() as any;
req.session.user = {
id: foundToken.userId,
};
await req.session.save();
await client.token.deleteMany({
where: {
userId: foundToken.userId,
},
});
res.json({
ok: true,
});
};
export default withApiSession(withHandler("POST", handler));
다음과 같이 token을 입력받고 confirm버튼을 누르게 되면, token에 해당하는 데이터와 user를 relation시켜서 찾습니다. 만약 토큰이 없다면 404를 띄우면 되고, 있다면 cookie session에 id값으로 token의 주인 id를 집어 넣어주면 됩니다. 그리고 이제 그 user에 해당하는 token은 필요 없으므로 싹다 삭제를 해주시면 됩니다.
그리고 마지막으로 {.ok: true }를 반환해 주면 api router작성이 끝나게 됩니다.
그리고 추가로 지금 session cookie의 주인을 확인할 수 있는 api router도 작성하였습니다.
/pages/api/users/me.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const profile = await client.user.findUnique({
where: {
id: req.session.user?.id,
},
});
res.json({
ok: true,
profile,
});
};
export default withApiSession(withHandler("GET", handler));
/pages/enter.tsx
import { useEffect, useState } from "react";
import type { NextPage } from "next";
import useMutation from "@libs/client/useMutation";
import { cls } from "@libs/client/utils";
import Button from "@components/button";
import Input from "@components/Input";
import { useForm } from "react-hook-form";
import { ConversationList } from "twilio/lib/rest/conversations/v1/service/conversation";
import { useRouter } from "next/router";
type MethodType = "email" | "phone";
interface EnterForm {
email?: string;
phone?: string;
}
interface TokenForm {
token: string;
}
interface EnterMutationResult {
ok: boolean;
}
interface MutationResult {
ok: boolean;
}
const Enter: NextPage = () => {
const [enter, { loading, data, error }] =
useMutation<EnterMutationResult>("/api/users/enter");
const [
confirmToken,
{
loading: tokenLoading,
data: tokenData,
error: tokenError,
},
] = useMutation<MutationResult>("/api/users/confirm");
const [submitting, setSubmitting] = useState(false);
const { register, handleSubmit, reset } = useForm<EnterForm>();
const {
register: tokenRegister,
handleSubmit: tokenHandleSubmit,
} = useForm<TokenForm>();
const [method, setMethod] = useState<MethodType>("email");
const onEmailClick = () => {
reset();
setMethod("email");
};
const onPhoneClick = () => {
reset();
setMethod("phone");
};
const onValid = (validForm: EnterForm) => {
enter(validForm);
};
const onTokenValid = (validForm: TokenForm) => {
if (tokenLoading) return;
confirmToken(validForm);
};
const router = useRouter();
useEffect(() => {
if (tokenData?.ok) {
router.push("/");
}
}, [tokenData, router]);
return (
<div className="mt-16 px-4">
<h3 className="text-center text-3xl font-bold">
Enter to Carrot
</h3>
<div className="mt-8">
{data?.ok ? (
<form
onSubmit={tokenHandleSubmit(onTokenValid)}
className="mt-8 flex flex-col"
>
<Input
register={tokenRegister("token", {
required: true,
})}
name="token"
label="Confirmation Token"
type="number"
required
/>
<Button
text={tokenLoading ? "Loading" : "Confirm Token"}
/>
</form>
) : (
<>
<div className="flex flex-col items-center">
<h5 className="text-sm font-medium text-gray-500">
Enter using:
</h5>
<div className="mt-8 grid w-full grid-cols-2 gap-16 border-b">
<button
className={cls(
"border-b-2 pb-4 font-medium",
method === "email"
? " border-orange-500 text-orange-500"
: "border-transparent text-gray-500"
)}
onClick={onEmailClick}
>
Email
</button>
<button
className={cls(
"border-b-2 pb-4 font-medium",
method === "phone"
? " border-orange-500 text-orange-500"
: "border-transparent text-gray-500"
)}
onClick={onPhoneClick}
>
Phone
</button>
</div>
</div>
<form
onSubmit={handleSubmit(onValid)}
className="mt-8 flex flex-col"
>
{method === "email" ? (
<Input
register={register("email", {
required: true,
})}
name="email"
label="Email address"
type="email"
/>
) : null}
{method === "phone" ? (
<Input
register={register("phone", {
required: true,
})}
name="phone"
label="Phone number"
type="number"
kind="phone"
/>
) : null}
{method === "email" ? (
<Button
text={loading ? "Loading" : "Get login link"}
/>
) : null}
{method === "phone" ? (
<Button
text={
loading ? "Loading" : "Get one-time password"
}
/>
) : null}
</form>
</>
)}
<div className="mt-8">
<div className="relative">
<div className="absolute w-full border-t border-gray-300" />
<div className="relative -top-3 text-center ">
<span className="bg-white px-2 text-sm text-gray-500">
Or enter with
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<button className="flex justify-center rounded-md border border-gray-500 bg-white py-2 px-4 text-sm font-medium text-gray-500 shadow-sm hover:bg-gray-50">
<svg
className="h-5 w-5"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
</svg>
</button>
<button className="flex justify-center rounded-md border border-gray-500 bg-white py-2 px-4 text-sm font-medium text-gray-500 shadow-sm hover:bg-gray-50">
<svg
className="h-5 w-5"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
);
};
export default Enter;
그리고 마지막으로 enter page에서 useEffect로 useMutation으로 받아온 값의 ok가 true가 된다면 드디어 메인페이지인 '/'로 redirection시켜주면 됩니다.
'Web > CloneCoding' 카테고리의 다른 글
[Carrot Market] #11 PRODUCTS - START (0) | 2022.05.17 |
---|---|
[Carrot Market] #10 - AUTHORIZATION (0) | 2022.05.13 |
[Carrot Market] #9 - Authentication - 1 (0) | 2022.05.12 |
[Carrot Market] #8 REFACTORING Form (0) | 2022.05.04 |
[Carrot Market] #7 React Hook Form (0) | 2022.05.03 |