Peace 4 weeks ago
parent 75e05dc5ab
commit 8ac669cabb
  1. 15
      web/.vscode/launch.json
  2. 4
      web/src/app/layout.tsx
  3. 27
      web/src/app/login/page.tsx
  4. 28
      web/src/app/me/page.tsx
  5. 6
      web/src/app/providers.tsx
  6. 195
      web/src/app/signup/page.tsx
  7. 43
      web/src/components/app-header.tsx
  8. 5
      web/src/components/guard.tsx
  9. 15
      web/src/lib/api.ts
  10. 21
      web/src/lib/token.ts

@ -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}"
}
]
}

@ -16,9 +16,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="ko"> <html lang="ko">
<body className="m-0"> <body className="m-0 bg-gray-50">
<AppHeader />
<Providers> <Providers>
<AppHeader />
<main className="mx-auto w-full">{children}</main> <main className="mx-auto w-full">{children}</main>
</Providers> </Providers>
</body> </body>

@ -18,10 +18,13 @@ import { setToken } from "@/lib/token";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import axios from "axios"; import axios from "axios";
import { useQueryClient } from "@tanstack/react-query";
import { MeResponse } from "@/hooks/useMe";
import Link from "next/link";
const LoginSchema = z.object({ const LoginSchema = z.object({
name: z.string().min(1, "이름을 입력하세요."), name: z.string().min(1, "이름을 입력하세요."),
password: z.string().min(1, "비밀번호를 입력하세요."), password: z.string().min(4, "비밀번호를 입력하세요."),
}); });
type LoginInput = z.infer<typeof LoginSchema>; type LoginInput = z.infer<typeof LoginSchema>;
@ -35,6 +38,7 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const onSubmit = async (values: LoginInput) => { const onSubmit = async (values: LoginInput) => {
setError(null); setError(null);
@ -42,7 +46,17 @@ export default function LoginPage() {
try { try {
const res = await api.post("/auth/login", values); const res = await api.post("/auth/login", values);
setToken(res.data.access_token); setToken(res.data.access_token);
router.push("/me");
// me 캐시 미리 채워넣기
await queryClient.prefetchQuery({
queryKey: ["me"],
queryFn: async (): Promise<MeResponse> => {
const res = await api.get("/auth/me");
return res.data;
},
});
router.replace("/me");
} catch (e: unknown) { } catch (e: unknown) {
console.log(e); console.log(e);
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
@ -60,7 +74,7 @@ export default function LoginPage() {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="mx-auto my-auto w-full max-w-sm"> <div className="mx-auto my-auto w-full max-w-sm">
<Card> <Card className="shadow-lg">
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
</CardHeader> </CardHeader>
@ -113,6 +127,13 @@ export default function LoginPage() {
{error && ( {error && (
<p className="text-center text-sm text-red-600">{error}</p> <p className="text-center text-sm text-red-600">{error}</p>
)} )}
<p className="text-center text-xs text-gray-500">
?{" "}
<Link href="/signup" className="underline">
</Link>
</p>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>

@ -1,6 +1,7 @@
"use client"; "use client";
import Guard from "@/components/guard"; import Guard from "@/components/guard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMe } from "@/hooks/useMe"; import { useMe } from "@/hooks/useMe";
export default function MePage() { export default function MePage() {
@ -8,18 +9,33 @@ export default function MePage() {
return ( return (
<Guard> <Guard>
<div className="space-y-4">
<h1 className="text-xl font-semibold"> </h1>
{isLoading && <p> </p>} {isLoading && <p> </p>}
{error && <p className="text-red-600"> </p>} {error && <p className="text-red-600"> </p>}
{!isLoading && !error && data && ( {!isLoading && !error && data && (
<pre className="rounded bg-gray-900 p-4 text-sm text-white"> <div className="flex items-center justify-center min-h-screen">
{JSON.stringify(data, null, 2)} <Card className="w-full max-w-sm shadow-lg">
</pre> <CardHeader className="flex flex-row items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center font-bold text-xl text-gray-500">
{data.name?.[0]}
</div>
<div>
<CardTitle>{data.name ?? "이름 없음"}</CardTitle>
<div className="text-gray-400 text-xs">ID: {data.id}</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{data.email && (
<div className="flex items-center gap-2 text-gray-700">
<span className="font-medium"></span>
<span className="truncate">{data.email}</span>
</div>
)} )}
</CardContent>
</Card>
</div> </div>
)}
</Guard> </Guard>
); );
} }

@ -1,12 +1,12 @@
"use client"; "use client";
import { loadTokenFromStorage } from "@/lib/token"; import { loadTokenFromStorage } from "@/lib/token";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default function Providers({ children }: { children: React.ReactNode }) { export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
useEffect(() => { useEffect(() => {
loadTokenFromStorage(); // 새로고침 토큰 복원 loadTokenFromStorage(); // 새로고침 토큰 복원
}, []); }, []);

@ -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<typeof SignupSchema>;
export default function SignupPage() {
const form = useForm<SignupInput>({
resolver: zodResolver(SignupSchema),
defaultValues: {
name: "",
password: "",
confirmPassword: "",
email: undefined,
},
mode: "onTouched",
});
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
const onSubmit = async (values: SignupInput) => {
setError(null);
setOk(null);
setLoading(true);
try {
const payload: Record<string, unknown> = {
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 (
<div className="flex items-center justify-center min-h-screen">
<div className="mx-auto w-full max-w-sm">
<Card className="shadow-lg">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-y-2 pb-4">
<Form {...form}>
<form
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
{/* name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="아이디"
autoComplete="username"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="이메일(선택)"
type="email"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* password */}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="비밀번호"
type="password"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* confirmPassword */}
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="비밀번호 확인"
type="password"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "처리 중..." : "가입하기"}
</Button>
{error && (
<p className="text-center text-sm text-red-600">{error}</p>
)}
{ok && (
<p className="text-center text-sm text-green-600">{ok}</p>
)}
<p className="text-center text-xs text-gray-500">
?{" "}
<Link href="/login" className="underline">
</Link>
</p>
</form>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}

@ -1,18 +1,33 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { Avatar, AvatarFallback } from "./ui/avatar"; 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 = [ const nav = [
{ href: "/", label: "홈" }, { href: "/", label: "홈" },
{ href: "/me", label: "내 정보" }, { href: "/me", label: "내 정보" },
{ href: "/login", label: "로그인" },
]; ];
export default function AppHeader() { export default function AppHeader() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const [authed, setAuthed] = useState<boolean>(hasToken());
useEffect(() => {
setAuthed(hasToken());
}, [pathname]); // pathname이 바뀔 때마다 effect 실행
const onLogout = () => {
setToken(null);
queryClient.removeQueries({ queryKey: ["me"], exact: true });
router.push("/login");
};
return ( return (
<header className="bg-gray-900 text-white"> <header className="bg-gray-900 text-white">
@ -36,6 +51,30 @@ export default function AppHeader() {
<Separator orientation="vertical" className="h-5 bg-white/30" /> <Separator orientation="vertical" className="h-5 bg-white/30" />
{authed ? (
<button
className="rounded bg-white/10 px-3 py-1 hover:bg-white/20 transition"
onClick={onLogout}
>
</button>
) : (
<div className="flex items-center gap-2">
<Link
className="rounded bg-blue-600 px-3 py-1 hover:bg-blue-700 transition"
href="/signup"
>
</Link>
<Link
className="rounded bg-gray-700 px-3 py-1 hover:bg-gray-600 transition"
href="/login"
>
</Link>
</div>
)}
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback>U</AvatarFallback> <AvatarFallback>U</AvatarFallback>
</Avatar> </Avatar>

@ -1,6 +1,6 @@
"use client"; "use client";
import { getToken } from "@/lib/token"; import { hasToken } from "@/lib/token";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -9,8 +9,7 @@ export default function Guard({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
const t = getToken(); if (!hasToken()) r.replace("/login");
if (!t) r.replace("/login");
else setReady(true); else setReady(true);
}, [r]); }, [r]);

@ -10,6 +10,12 @@ api.interceptors.request.use((config) => {
if (t) { if (t) {
config.headers = config.headers ?? {}; config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${t}`; config.headers.Authorization = `Bearer ${t}`;
return config;
}
if (config.headers && "Authorization" in config.headers) {
delete config.headers.Authorization;
} }
return config; return config;
@ -18,7 +24,14 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(res) => res, (res) => res,
(error) => { (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); // 토큰 파기 setToken(null); // 토큰 파기
window.location.href = "/login"; // 전역 리다이렉트 window.location.href = "/login"; // 전역 리다이렉트
} }

@ -1,17 +1,26 @@
const itemKey = "access_token"; const KEY = "access_token";
let token: string | null = null; 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) => { export const setToken = (t: string | null) => {
token = t; token = t;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
if (t) localStorage.setItem(itemKey, t); if (t) localStorage.setItem(KEY, t);
else localStorage.removeItem(itemKey); else localStorage.removeItem(KEY);
} }
}; };
export const loadTokenFromStorage = () => { export const loadTokenFromStorage = () => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
token = localStorage.getItem(itemKey); token = localStorage.getItem(KEY);
}; };

Loading…
Cancel
Save