diff --git a/web/.vscode/launch.json b/web/.vscode/launch.json
new file mode 100644
index 0000000..9244737
--- /dev/null
+++ b/web/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+ // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
+ // 기존 특성에 대한 설명을 보려면 가리킵니다.
+ // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "chrome",
+ "request": "launch",
+ "name": "Next.js cliend debug",
+ "url": "http://localhost:3000",
+ "webRoot": "${workspaceFolder}"
+ }
+ ]
+}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 98a2e75..5925a88 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -16,9 +16,9 @@ export default function RootLayout({
}>) {
return (
-
-
+
+
{children}
diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx
index f834318..ffaa1b7 100644
--- a/web/src/app/login/page.tsx
+++ b/web/src/app/login/page.tsx
@@ -18,10 +18,13 @@ import { setToken } from "@/lib/token";
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import axios from "axios";
+import { useQueryClient } from "@tanstack/react-query";
+import { MeResponse } from "@/hooks/useMe";
+import Link from "next/link";
const LoginSchema = z.object({
name: z.string().min(1, "이름을 입력하세요."),
- password: z.string().min(1, "비밀번호를 입력하세요."),
+ password: z.string().min(4, "비밀번호를 입력하세요."),
});
type LoginInput = z.infer;
@@ -35,6 +38,7 @@ export default function LoginPage() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
+ const queryClient = useQueryClient();
const onSubmit = async (values: LoginInput) => {
setError(null);
@@ -42,7 +46,17 @@ export default function LoginPage() {
try {
const res = await api.post("/auth/login", values);
setToken(res.data.access_token);
- router.push("/me");
+
+ // me 캐시 미리 채워넣기
+ await queryClient.prefetchQuery({
+ queryKey: ["me"],
+ queryFn: async (): Promise => {
+ const res = await api.get("/auth/me");
+ return res.data;
+ },
+ });
+
+ router.replace("/me");
} catch (e: unknown) {
console.log(e);
if (axios.isAxiosError(e)) {
@@ -60,7 +74,7 @@ export default function LoginPage() {
return (
-
+
로그인
@@ -113,6 +127,13 @@ export default function LoginPage() {
{error && (
{error}
)}
+
+
+ 아직 계정이 없으신가요?{" "}
+
+ 회원가입
+
+
diff --git a/web/src/app/me/page.tsx b/web/src/app/me/page.tsx
index b76b141..764072b 100644
--- a/web/src/app/me/page.tsx
+++ b/web/src/app/me/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Guard from "@/components/guard";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMe } from "@/hooks/useMe";
export default function MePage() {
@@ -8,18 +9,33 @@ export default function MePage() {
return (
-
-
내 정보
+ {isLoading &&
불러오는 중…
}
+ {error &&
조회 실패
}
- {isLoading &&
불러오는 중…
}
- {error &&
조회 실패
}
+ {!isLoading && !error && data && (
+
+
+
+
+ {data.name?.[0]}
+
+
+
{data.name ?? "이름 없음"}
+
ID: {data.id}
+
+
- {!isLoading && !error && data && (
-
- {JSON.stringify(data, null, 2)}
-
- )}
-
+
+ {data.email && (
+
+ 이메일
+ {data.email}
+
+ )}
+
+
+
+ )}
);
}
diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx
index ae5140e..6273b0a 100644
--- a/web/src/app/providers.tsx
+++ b/web/src/app/providers.tsx
@@ -1,12 +1,12 @@
"use client";
import { loadTokenFromStorage } from "@/lib/token";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-const queryClient = new QueryClient();
-
export default function Providers({ children }: { children: React.ReactNode }) {
+ const [queryClient] = useState(() => new QueryClient());
+
useEffect(() => {
loadTokenFromStorage(); // 새로고침 토큰 복원
}, []);
diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx
new file mode 100644
index 0000000..6c4ee10
--- /dev/null
+++ b/web/src/app/signup/page.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { api } from "@/lib/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import z, { email } from "zod";
+
+const SignupSchema = z
+ .object({
+ name: z.string().min(1, "아이디를 입력하세요."),
+ password: z.string().min(4, "비밀번호는 4자 이상이어야 합니다."),
+ confirmPassword: z.string().min(1, "비밀번호 확인을 입력하세요."),
+ email: z.email("이메일 형식이 아닙니다.").optional(),
+ })
+ .refine((v) => v.password === v.confirmPassword, {
+ path: ["confirmPassword"],
+ message: "비밀번호가 일치하지 않습니다.",
+ });
+
+type SignupInput = z.infer;
+
+export default function SignupPage() {
+ const form = useForm({
+ resolver: zodResolver(SignupSchema),
+ defaultValues: {
+ name: "",
+ password: "",
+ confirmPassword: "",
+ email: undefined,
+ },
+ mode: "onTouched",
+ });
+
+ const [error, setError] = useState(null);
+ const [ok, setOk] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const onSubmit = async (values: SignupInput) => {
+ setError(null);
+ setOk(null);
+ setLoading(true);
+ try {
+ const payload: Record = {
+ name: values.name.trim(),
+ password: values.password,
+ };
+ const emailTrim = values.email?.trim();
+ if (emailTrim) payload.email = emailTrim;
+
+ await api.post("/auth/signup", payload);
+ setOk("회원가입이 완료되어습니다. 로그인 페이지로 이동합니다.");
+ setTimeout(() => router.replace("/login"), 800);
+ } catch (e: unknown) {
+ if (axios.isAxiosError(e)) {
+ setError(
+ e.request?.data?.message ?? e.message ?? "회원가입에 실패했습니다."
+ );
+ } else if (e instanceof Error) {
+ setError(e.message);
+ } else {
+ setError("회원가입에 실패했습니다.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/web/src/components/app-header.tsx b/web/src/components/app-header.tsx
index 16b925c..595be4d 100644
--- a/web/src/components/app-header.tsx
+++ b/web/src/components/app-header.tsx
@@ -1,18 +1,33 @@
"use client";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { Separator } from "./ui/separator";
import { Avatar, AvatarFallback } from "./ui/avatar";
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { hasToken, setToken } from "@/lib/token";
const nav = [
{ href: "/", label: "홈" },
{ href: "/me", label: "내 정보" },
- { href: "/login", label: "로그인" },
];
export default function AppHeader() {
const pathname = usePathname();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [authed, setAuthed] = useState(hasToken());
+
+ useEffect(() => {
+ setAuthed(hasToken());
+ }, [pathname]); // pathname이 바뀔 때마다 effect 실행
+
+ const onLogout = () => {
+ setToken(null);
+ queryClient.removeQueries({ queryKey: ["me"], exact: true });
+ router.push("/login");
+ };
return (
@@ -36,6 +51,30 @@ export default function AppHeader() {
+ {authed ? (
+
+ ) : (
+
+
+ 회원가입
+
+
+ 로그인
+
+
+ )}
+
U
diff --git a/web/src/components/guard.tsx b/web/src/components/guard.tsx
index 3e68c62..871be7a 100644
--- a/web/src/components/guard.tsx
+++ b/web/src/components/guard.tsx
@@ -1,6 +1,6 @@
"use client";
-import { getToken } from "@/lib/token";
+import { hasToken } from "@/lib/token";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -9,8 +9,7 @@ export default function Guard({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
useEffect(() => {
- const t = getToken();
- if (!t) r.replace("/login");
+ if (!hasToken()) r.replace("/login");
else setReady(true);
}, [r]);
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 253dac4..27a7bd6 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -10,6 +10,12 @@ api.interceptors.request.use((config) => {
if (t) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${t}`;
+
+ return config;
+ }
+
+ if (config.headers && "Authorization" in config.headers) {
+ delete config.headers.Authorization;
}
return config;
@@ -18,7 +24,14 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use(
(res) => res,
(error) => {
- if (error?.response?.status === 401 && typeof window !== "undefined") {
+ const url: string | undefined = error?.config?.url;
+ const isAuthRoute =
+ url?.includes("/auth/login") || url?.includes("/auth/signup");
+ if (
+ error?.response?.status === 401 &&
+ typeof window !== "undefined" &&
+ !isAuthRoute
+ ) {
setToken(null); // 토큰 파기
window.location.href = "/login"; // 전역 리다이렉트
}
diff --git a/web/src/lib/token.ts b/web/src/lib/token.ts
index 99e72b7..8c44311 100644
--- a/web/src/lib/token.ts
+++ b/web/src/lib/token.ts
@@ -1,17 +1,26 @@
-const itemKey = "access_token";
-let token: string | null = null;
+const KEY = "access_token";
+let token: string | null | undefined;
-export const getToken = () => token;
+export const getToken = (): string | null => {
+ if (typeof window === "undefined") return null;
+ if (token === undefined) {
+ token = localStorage.getItem(KEY) ?? null;
+ }
+
+ return token ?? null;
+};
+
+export const hasToken = (): boolean => !!getToken();
export const setToken = (t: string | null) => {
token = t;
if (typeof window !== "undefined") {
- if (t) localStorage.setItem(itemKey, t);
- else localStorage.removeItem(itemKey);
+ if (t) localStorage.setItem(KEY, t);
+ else localStorage.removeItem(KEY);
}
};
export const loadTokenFromStorage = () => {
if (typeof window === "undefined") return;
- token = localStorage.getItem(itemKey);
+ token = localStorage.getItem(KEY);
};