diff --git a/package-lock.json b/package-lock.json index f2127ec..12ee2de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "focus-trap-react": "^12.0.0", "framer-motion": "^12.38.0", "next": "16.1.6", + "next-auth": "^4.24.14", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", @@ -244,6 +245,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1674,6 +1684,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3327,6 +3346,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -5461,6 +5489,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6277,6 +6314,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.14", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", + "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6312,6 +6381,12 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6322,6 +6397,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6435,6 +6519,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -6451,6 +6544,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6649,6 +6775,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6754,6 +6902,12 @@ } } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7994,6 +8148,16 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 9c8629e..717255d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "focus-trap-react": "^12.0.0", "framer-motion": "^12.38.0", "next": "16.1.6", + "next-auth": "^4.24.14", "react": "19.2.3", "react-dom": "19.2.3", "tailwind-merge": "^3.5.0", diff --git a/src/app/(before-login)/(auth)/login/page.tsx b/src/app/(before-login)/(auth)/login/page.tsx index c207960..2314e26 100644 --- a/src/app/(before-login)/(auth)/login/page.tsx +++ b/src/app/(before-login)/(auth)/login/page.tsx @@ -1,3 +1,158 @@ +'use client'; + +import { useState } from 'react'; +import { signIn } from 'next-auth/react'; +import Image from 'next/image'; +import Link from 'next/link'; +import Button from '@/components/Buttons'; +import Icon from '@/components/Icon'; +import Input from '@/components/Input'; + +interface LoginForm { + email: string; + password: string; +} + +interface LoginErrors { + email?: string; + password?: string; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + export default function Page() { - return
로그인 페이지
; + const [form, setForm] = useState({ email: '', password: '' }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + + function validateEmail(value: string): string | undefined { + if (!value) return '이메일은 필수 입력입니다.'; + if (!EMAIL_REGEX.test(value)) return '이메일 형식으로 작성해 주세요.'; + return undefined; + } + + function validatePassword(value: string): string | undefined { + if (!value) return '비밀번호는 필수 입력입니다.'; + return undefined; + } + + function handleEmailBlur() { + setErrors((prev) => ({ ...prev, email: validateEmail(form.email) })); + } + + function handlePasswordBlur() { + setErrors((prev) => ({ ...prev, password: validatePassword(form.password) })); + } + + const isFormValid = + form.email.trim() !== '' && form.password.trim() !== '' && !errors.email && !errors.password; + + async function handleSubmit() { + const emailError = validateEmail(form.email); + const passwordError = validatePassword(form.password); + setErrors({ email: emailError, password: passwordError }); + if (emailError || passwordError) return; + + setLoading(true); + + const result = await signIn('credentials', { + email: form.email, + password: form.password, + redirect: false, + }); + + setLoading(false); + + if (result?.error) { + setErrors({ email: '이메일 혹은 비밀번호를 확인해주세요.' }); + return; + } + + window.location.href = '/workspace'; + } + + return ( +
+
+
+ + Re:Mate logo + +
+ +
+ { + setForm((prev) => ({ ...prev, email: e.target.value })); + if (errors.email) { + setErrors((prev) => ({ ...prev, email: validateEmail(e.target.value) })); + } + }} + onBlur={handleEmailBlur} + /> + { + setForm((prev) => ({ ...prev, password: e.target.value })); + if (errors.password) { + setErrors((prev) => ({ ...prev, password: validatePassword(e.target.value) })); + } + }} + onBlur={handlePasswordBlur} + onKeyDown={(e) => e.key === 'Enter' && isFormValid && handleSubmit()} + /> +
+ +
+ + + +
+ +

+ 계정이 없으신가요?{' '} + + 회원가입하기 + +

+
+
+ ); } diff --git a/src/app/(before-login)/(auth)/signup/_actions.ts b/src/app/(before-login)/(auth)/signup/_actions.ts new file mode 100644 index 0000000..c3b3503 --- /dev/null +++ b/src/app/(before-login)/(auth)/signup/_actions.ts @@ -0,0 +1,34 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { ApiError, signupApi } from '@/lib/auth-api'; + +interface SignupActionResult { + error?: string; + field?: 'email' | 'password' | 'form'; +} + +export async function signupAction( + email: string, + name: string, + password: string, +): Promise { + try { + await signupApi({ email, name, password }); + } catch (e) { + if (e instanceof ApiError) { + if (e.status === 409) { + return { error: '이미 가입된 이메일입니다.', field: 'email' }; + } + if (e.status === 400) { + return { + error: '비밀번호는 영문, 숫자, 특수문자 포함 6자 이상입니다.', + field: 'password', + }; + } + } + return { error: '회원가입 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', field: 'form' }; + } + + redirect('/login'); +} diff --git a/src/app/(before-login)/(auth)/signup/page.tsx b/src/app/(before-login)/(auth)/signup/page.tsx index 8a0ca7c..afb76f4 100644 --- a/src/app/(before-login)/(auth)/signup/page.tsx +++ b/src/app/(before-login)/(auth)/signup/page.tsx @@ -1,3 +1,224 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import Button from '@/components/Buttons'; +import Input from '@/components/Input'; +import { signupAction } from './_actions'; + +interface SignupForm { + email: string; + name: string; + password: string; + passwordConfirm: string; +} + +interface SignupErrors { + email?: string; + name?: string; + password?: string; + passwordConfirm?: string; + form?: string; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PASSWORD_REGEX = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*])[^\s]{6,}$/; + export default function Page() { - return
회원가입 페이지
; + const [form, setForm] = useState({ + email: '', + name: '', + password: '', + passwordConfirm: '', + }); + const [errors, setErrors] = useState({}); + const [isPending, startTransition] = useTransition(); + + function validateEmail(value: string): string | undefined { + if (!value) return '이메일은 필수 입력입니다.'; + if (!EMAIL_REGEX.test(value)) return '이메일 형식으로 작성해 주세요.'; + return undefined; + } + + function validateName(value: string): string | undefined { + if (!value) return '이름은 필수 입력입니다.'; + return undefined; + } + + function validatePassword(value: string): string | undefined { + if (!value) return '비밀번호는 필수 입력입니다.'; + if (value.length < 6) return '6자 이상 입력해 주세요.'; + if (!PASSWORD_REGEX.test(value)) return '비밀번호는 영문, 숫자, 특수문자 포함 6자 이상입니다.'; + return undefined; + } + + function validatePasswordConfirm(value: string, password: string): string | undefined { + if (!value) return '비밀번호 확인은 필수 입력입니다.'; + if (value !== password) return '비밀번호가 일치하지 않습니다.'; + return undefined; + } + + function handleEmailBlur() { + setErrors((prev) => ({ ...prev, email: validateEmail(form.email) })); + } + + function handleNameBlur() { + setErrors((prev) => ({ ...prev, name: validateName(form.name) })); + } + + function handlePasswordBlur() { + setErrors((prev) => ({ ...prev, password: validatePassword(form.password) })); + } + + function handlePasswordConfirmBlur() { + setErrors((prev) => ({ + ...prev, + passwordConfirm: validatePasswordConfirm(form.passwordConfirm, form.password), + })); + } + + const isFormValid = + form.email.trim() !== '' && + form.name.trim() !== '' && + form.password.trim() !== '' && + form.passwordConfirm.trim() !== '' && + !errors.email && + !errors.name && + !errors.password && + !errors.passwordConfirm; + + function handleSubmit() { + const emailError = validateEmail(form.email); + const nameError = validateName(form.name); + const passwordError = validatePassword(form.password); + const passwordConfirmError = validatePasswordConfirm(form.passwordConfirm, form.password); + + setErrors({ + email: emailError, + name: nameError, + password: passwordError, + passwordConfirm: passwordConfirmError, + }); + + if (emailError || nameError || passwordError || passwordConfirmError) return; + + startTransition(async () => { + const result = await signupAction(form.email, form.name, form.password); + if (result?.error) { + if (result.field === 'email') { + setErrors((prev) => ({ ...prev, email: result.error })); + } else if (result.field === 'password') { + setErrors((prev) => ({ ...prev, password: result.error })); + } else { + setErrors((prev) => ({ ...prev, form: result.error })); + } + } + }); + } + + return ( +
+
+
+ + Re:Mate logo + +
+ +
+ { + setForm((prev) => ({ ...prev, email: e.target.value })); + if (errors.email) { + setErrors((prev) => ({ ...prev, email: validateEmail(e.target.value) })); + } + }} + onBlur={handleEmailBlur} + /> + { + setForm((prev) => ({ ...prev, name: e.target.value })); + if (errors.name) { + setErrors((prev) => ({ ...prev, name: validateName(e.target.value) })); + } + }} + onBlur={handleNameBlur} + /> + { + setForm((prev) => ({ ...prev, password: e.target.value })); + if (errors.password) { + setErrors((prev) => ({ ...prev, password: validatePassword(e.target.value) })); + } + }} + onBlur={handlePasswordBlur} + /> + { + setForm((prev) => ({ ...prev, passwordConfirm: e.target.value })); + if (errors.passwordConfirm) { + setErrors((prev) => ({ + ...prev, + passwordConfirm: validatePasswordConfirm(e.target.value, form.password), + })); + } + }} + onBlur={handlePasswordConfirmBlur} + onKeyDown={(e) => e.key === 'Enter' && isFormValid && handleSubmit()} + /> +
+ + {errors.form && ( +

{errors.form}

+ )} + +
+ +
+ +

+ 계정이 이미 있으신가요?{' '} + + 로그인하기 + +

+
+
+ ); } diff --git a/src/app/(before-login)/(landing)/page.tsx b/src/app/(before-login)/(landing)/page.tsx index 16649ce..54653e9 100644 --- a/src/app/(before-login)/(landing)/page.tsx +++ b/src/app/(before-login)/(landing)/page.tsx @@ -1,3 +1,10 @@ +import TempLogoutButton from '@/components/TempLogoutButton'; + export default function Home() { - return
랜딩 페이지
; + return ( + <> +
랜딩 페이지
+ + + ); } diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..731e18f --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,62 @@ +import NextAuth, { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { signinApi } from '@/lib/auth-api'; + +export const authOptions: NextAuthOptions = { + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) return null; + + try { + const res = await signinApi({ + email: credentials.email, + password: credentials.password, + }); + + const user = res.data; + + return { + id: String(user.userId), + email: user.email, + name: user.name, + accessToken: user.accessToken, + }; + } catch { + return null; + } + }, + }), + ], + + callbacks: { + async jwt({ token, user }) { + if (user) { + token.accessToken = user.accessToken; + token.userId = user.id; + } + return token; + }, + async session({ session, token }) { + session.accessToken = token.accessToken; + session.user.id = token.userId; + return session; + }, + }, + + pages: { + signIn: '/login', + }, + + session: { + strategy: 'jwt', + }, +}; + +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7b4da5d..2b03a3c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import localFont from 'next/font/local'; import './globals.css'; +import AuthProvider from '@/components/AuthProvider'; import ModalRoot from '@/components/modal/ModalRoot'; const pretendard = localFont({ @@ -16,7 +17,7 @@ export default function RootLayout({ return ( - {children} + {children}