From 8ac669cabbbf5d1bdccecff89ba2387fda3e207d Mon Sep 17 00:00:00 2001 From: Peace Date: Mon, 25 Aug 2025 17:23:25 +0900 Subject: [PATCH] fe j --- web/.vscode/launch.json | 15 +++ web/src/app/layout.tsx | 4 +- web/src/app/login/page.tsx | 27 ++++- web/src/app/me/page.tsx | 36 ++++-- web/src/app/providers.tsx | 6 +- web/src/app/signup/page.tsx | 195 ++++++++++++++++++++++++++++++ web/src/components/app-header.tsx | 43 ++++++- web/src/components/guard.tsx | 5 +- web/src/lib/api.ts | 15 ++- web/src/lib/token.ts | 21 +++- 10 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 web/.vscode/launch.json create mode 100644 web/src/app/signup/page.tsx 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 ( +
+
+ + + 회원가입 + + +
+ + {/* name */} + ( + + + + + + + )} + /> + + {/* email */} + ( + + + + + + + )} + /> + + {/* password */} + ( + + + + + + + )} + /> + + {/* confirmPassword */} + ( + + + + + + + )} + /> + + + + {error && ( +

{error}

+ )} + {ok && ( +

{ok}

+ )} + +

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

+ + +
+
+
+
+ ); +} 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); };