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. 36
      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 (
<html lang="ko">
<body className="m-0">
<AppHeader />
<body className="m-0 bg-gray-50">
<Providers>
<AppHeader />
<main className="mx-auto w-full">{children}</main>
</Providers>
</body>

@ -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<typeof LoginSchema>;
@ -35,6 +38,7 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(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<MeResponse> => {
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 (
<div className="flex items-center justify-center min-h-screen">
<div className="mx-auto my-auto w-full max-w-sm">
<Card>
<Card className="shadow-lg">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
@ -113,6 +127,13 @@ export default function LoginPage() {
{error && (
<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>
</CardContent>

@ -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 (
<Guard>
<div className="space-y-4">
<h1 className="text-xl font-semibold"> </h1>
{isLoading && <p> </p>}
{error && <p className="text-red-600"> </p>}
{isLoading && <p> </p>}
{error && <p className="text-red-600"> </p>}
{!isLoading && !error && data && (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-sm shadow-lg">
<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>
{!isLoading && !error && data && (
<pre className="rounded bg-gray-900 p-4 text-sm text-white">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
<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>
)}
</Guard>
);
}

@ -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(); // 새로고침 토큰 복원
}, []);

@ -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";
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<boolean>(hasToken());
useEffect(() => {
setAuthed(hasToken());
}, [pathname]); // pathname이 바뀔 때마다 effect 실행
const onLogout = () => {
setToken(null);
queryClient.removeQueries({ queryKey: ["me"], exact: true });
router.push("/login");
};
return (
<header className="bg-gray-900 text-white">
@ -36,6 +51,30 @@ export default function AppHeader() {
<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">
<AvatarFallback>U</AvatarFallback>
</Avatar>

@ -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]);

@ -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"; // 전역 리다이렉트
}

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

Loading…
Cancel
Save