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 (
+
+
+
+
+
+
+
+
+
+ {
+ 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 (
+
+
+
+
+
+
+
+
+
+ {
+ 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}
diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx
new file mode 100644
index 0000000..be51c14
--- /dev/null
+++ b/src/components/AuthProvider.tsx
@@ -0,0 +1,7 @@
+'use client';
+
+import { SessionProvider } from 'next-auth/react';
+
+export default function AuthProvider({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/components/Input.tsx b/src/components/Input.tsx
index 0fb041b..77aa4cb 100644
--- a/src/components/Input.tsx
+++ b/src/components/Input.tsx
@@ -71,6 +71,7 @@ const Input = forwardRef(
variant,
hasError: Boolean(error),
}),
+ isPassword && 'pr-12 md:pr-14',
className,
)}
{...props}
diff --git a/src/components/TempLogoutButton.tsx b/src/components/TempLogoutButton.tsx
new file mode 100644
index 0000000..e05a06e
--- /dev/null
+++ b/src/components/TempLogoutButton.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { signOut } from 'next-auth/react';
+import Button from './Buttons';
+
+export default function TempLogoutButton() {
+ async function handleLogout() {
+ await signOut({ callbackUrl: '/login' });
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/lib/auth-api.ts b/src/lib/auth-api.ts
new file mode 100644
index 0000000..ae1771a
--- /dev/null
+++ b/src/lib/auth-api.ts
@@ -0,0 +1,44 @@
+import { AuthResponse, SigninRequest, SignupRequest } from '@/types/auth';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL;
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+export async function signupApi(body: SignupRequest): Promise {
+ const res = await fetch(`${API_URL}/api/v1/auth/signup`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ const data: AuthResponse = await res.json();
+
+ if (!res.ok || !data.success) {
+ throw new ApiError(res.status, '회원가입에 실패했습니다.');
+ }
+
+ return data;
+}
+
+export async function signinApi(body: SigninRequest): Promise {
+ const res = await fetch(`${API_URL}/api/v1/auth/signin`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ const data: AuthResponse = await res.json();
+
+ if (!res.ok || !data.success) {
+ throw new ApiError(res.status, '이메일 혹은 비밀번호를 확인해주세요.');
+ }
+
+ return data;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..bc768ea
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,26 @@
+import { getToken } from 'next-auth/jwt';
+import { NextResponse, NextRequest } from 'next/server';
+
+export async function middleware(req: NextRequest) {
+ const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
+ const isLoggedIn = !!token;
+ const { pathname } = req.nextUrl;
+
+ const isAuthPage = pathname.startsWith('/login') || pathname.startsWith('/signup');
+
+ const isProtectedPage = pathname.startsWith('/workspace') || pathname.startsWith('/mypage');
+
+ if (isProtectedPage && !isLoggedIn) {
+ return NextResponse.redirect(new URL('/login', req.url));
+ }
+
+ if (isAuthPage && isLoggedIn) {
+ return NextResponse.redirect(new URL('/workspace', req.url));
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
+};
diff --git a/src/types/auth.ts b/src/types/auth.ts
new file mode 100644
index 0000000..ec15f7e
--- /dev/null
+++ b/src/types/auth.ts
@@ -0,0 +1,28 @@
+export interface SignupRequest {
+ email: string;
+ name: string;
+ password: string;
+}
+
+export interface SigninRequest {
+ email: string;
+ password: string;
+}
+
+export interface AuthUser {
+ accessToken: string;
+ email: string;
+ name: string;
+ workspaceCount: number;
+ lastWorkspaceId: number | null;
+ userId: number;
+}
+
+export interface AuthResponse {
+ success: boolean;
+ data: AuthUser;
+ meta: {
+ timestamp: string;
+ traceId: string;
+ };
+}
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..444f793
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,4 @@
+declare module '*.css' {
+ const content: { [className: string]: string };
+ export default content;
+}
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
new file mode 100644
index 0000000..74e0ebc
--- /dev/null
+++ b/src/types/next-auth.d.ts
@@ -0,0 +1,27 @@
+import 'next-auth';
+import 'next-auth/jwt';
+
+declare module 'next-auth' {
+ interface Session {
+ accessToken: string;
+ user: {
+ id: string;
+ email: string;
+ name: string;
+ };
+ }
+
+ interface User {
+ id: string;
+ email: string;
+ name: string;
+ accessToken: string;
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ accessToken: string;
+ userId: string;
+ }
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index e9167db..e99273f 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -29,7 +29,7 @@ export default {
400: '#000000',
},
gray: {
- 100: '#FAFAFA',
+ 100: '#F8FAFC',
200: '#EEEEEE',
300: '#D9D9D9',
400: '#9FA6B2',