// main.tsx
export default function Main() {
return (
<>
<div className={styles.left}>
<Image src={zLogo} alt="logo" />
</div>
<div className={styles.right}>
<h1>지금 일어나고 있는 일</h1>
<h2>지금 가입하세요.</h2>
<Link href="/signup" className={styles.signup}>계정 만들기</Link>
<h3>이미 트위터에 가입하셨나요?</h3>
<Link href="/login" className={styles.login}>로그인</Link>
</div>
</>
)
}
main 페이지에서 계정 만들기 버튼을 클릭하면 /signup 페이지로 라우팅 이동 시키고 /signup 페이지에서 @modal/(.i)/flow/signup 으로 Parallel Routes & Intercepting 시켜야 한다.
하지만 서버 컴포넌트(/signup)에서는 useRouter를 사용할 수 없을뿐더러 redirect 시킬경우 인터셉팅이 작동하지 않는다. 따라서 Client Component(RedirectToSignup.tsx)를 만들어 라우팅 시켜준다.
// (beforeLogin)/signup/page.tsx
import Main from "@/app/(beforeLogin)/_component/Main";
import {auth} from "@/auth";
import {redirect} from "next/navigation";
import RedirectToSignup from "@/app/(beforeLogin)/signup/_component/RedirectToSignup";
export default async function Signup() {
const session = await auth();
if (session?.user) {
redirect('/home');
return null;
}
return (
<>
<RedirectToSignup />
<Main/>
</>
);
}
// RedirectToSignup.tsx
"use client";
import {useEffect} from "react";
import {useRouter} from "next/navigation";
export default function RedirectToSignup() {
const router = useRouter();
useEffect(() => {
router.replace('/i/flow/signup');
}, []);
return null;
}
즉, 로그인 버튼을 클릭하면 /login/page.tsx 로 이동했다가 내부 RedirectToLogin.tsx 컴포넌트에 의해 (beforeLogin)/i/flow/login/page.tsx로 이동하는데, 인터셉팅 되어 (beforeLogin)/@modal/i/flow/login/page.tsx 화면이 보여진다.
회원가입 기능은 Server Action으로 처리했으며 발생하는 에러는 크롬 개발자 도구가 아닌 프론트 서버(터미널) 에서 확인이 가능하다.
SignupModal 컴포넌트에서는 useFormState 훅을 사용했다.
// (beforeLogin)/_component/SignupModal.tsx
"use client"
import style from './signup.module.css';
import onSubmit from '../_lib/signup'
import BackButton from "@/app/(beforeLogin)/_component/BackButton";
import { useFormState, useFormStatus } from 'react-dom'
function showMessage(message: string | undefined) {
switch (message) {
case 'no_id':
return '아이디를 입력하세요.';
case 'no_name':
return '닉네임을 입력하세요.';
case 'no_password':
return '비밀번호를 입력하세요.';
case 'no_image':
return '이미지를 업로드하세요.';
case 'user_exists':
return '이미 사용 중인 아이디입니다.';
default:
return ''
}
}
export default function SignupModal() {
const [state, formAction] = useFormState(onSubmit, { message: '' });
const {pending} = useFormStatus();
return (
<>
<div className={style.modalBackground}>
<div className={style.modal}>
<div className={style.modalHeader}>
<BackButton/>
<div>계정을 생성하세요.</div>
</div>
<form action={formAction}>
<div className={style.modalBody}>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="id">아이디</label>
<input id="id" name="id" className={style.input} type="text" placeholder="" required/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="name">닉네임</label>
<input id="name" name="name" className={style.input} type="text" placeholder="" required/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="password">비밀번호</label>
<input id="password" name="password" className={style.input} type="password" placeholder="" required/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="image">프로필</label>
<input id="image" name="image" className={style.input} type="file" accept="image/*" required/>
</div>
</div>
<div className={style.modalFooter}>
<button type="submit" className={style.actionButton} disabled={pending}>가입하기</button>
<div className={style.error}>{showMessage(state?.message)}</div>
</div>
</form>
</div>
</div>
</>)
}
// (beforeLogin)/_lib/signup.ts
"use server"
import {redirect} from "next/navigation";
import {signIn} from "@/auth";
export default async (prevState: any, formData: FormData) =>{
// Server Action
// 서버에서 실행되므로 브라우저에 노출되지 않는다.
if(!formData.get('id') || !(formData.get('id') as string)?.trim()){
return {message: 'no_id'}
}
if(!formData.get('name') || !(formData.get('name') as string)?.trim()){
return {message: 'no_name'}
}
if(!formData.get('password') || !(formData.get('password') as string)?.trim()){
return {message: 'no_password'}
}
if(!formData.get('image') ){
return {message: 'no_image'}
}
formData.set('nickname', formData.get('name') as string)
let shouldRedirect = false;
try{
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/users`,{
method: 'post',
body: formData,
// 회원가입 시 세션 쿠키를 브라우저에 등록하기 위해 추가한 옵션
credentials: 'include'
})
if(response.status === 403){
return {message: 'user_exists'}
}
shouldRedirect=true
console.log('$$$$ signUp success: ', response)
// 회원가입 후 로그인 처리
// Server Action 이므로 @/auth 의 signIn 을 사용한다 ( /next-auth/react X)
await signIn("credentials", {
username: formData.get('id'),
password: formData.get('password'),
redirect: false,
})
console.log('$$$$ signIn success: ', response)
}catch (e) {
console.error(e)
}
if(shouldRedirect){
// redirect 는 try&catch 문에서는 사용이 불가능하기 때문
redirect('/home')
}
}
Session을 사용하기 위해서는 cookie가 브라우저에 등록되어야 하는데, 그 것을 위해 fetch 부분에 {credentials: 'include'} 옵션을 추가해준 것이다.
회원가입이 성공적으로 완료되면 signIn을 호출해 로그인 처리했다.
//next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/upload/:slug',
destination: `${process.env.NEXT_PUBLIC_BASE_URL}/upload/:slug`, // Matched parameters can be used in the destination
},
]
},
}
module.exports = nextConfig
api에서 이미지 url 을 '/upload/~~ ' 형식으로 내려주기 때문에 rewrites 기능을 사용해 주소를 변경해 주었다.
💡 rewrites
특정 경로를 다른 경로로 매핑시키는 기능이며 next.config.js 파일에서 사용 가능하다.