Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ dist-ssr
.ionide

### macOS ###
.DS_Store
.DS_Store

## Environment Variables ###
.env
.env.production
.env.development
*.local
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.11.0"
"react-router": "^7.11.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
Expand Down
21 changes: 21 additions & 0 deletions src/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import axios from 'axios';
import useAuthStore from '../hooks/useAuthStore';

// 곡톡 섀정을 κ°€μ§„ axios μΈμŠ€ν„΄μŠ€ 생성
const apiClient = axios.create({
baseURL: '/api', // λͺ¨λ“  μš”μ²­μ˜ κΈ°λ³Έ 경둜
timeout: 5000,
});

// μš”μ²­ 인터셉터: λͺ¨λ“  μš”μ²­ 직전에 싀행됨
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;

if (token) {
config.headers.Authorization = `Bearer ${token}`;
}

return config;
});

export default apiClient;
7 changes: 7 additions & 0 deletions src/api/auth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { AuthResponse, LoginRequest } from '../../types/auth';
import apiClient from '../apiClient';

export default async function login(data: LoginRequest): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/login', data);
return response.data;
}
7 changes: 7 additions & 0 deletions src/api/auth/me.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { User, UserGroup } from '../../types/auth';
import apiClient from '../apiClient';

export default async function getMe(): Promise<User & { groups: UserGroup[] }> {
const response = await apiClient.get('/auth/me');
return response.data;
}
19 changes: 19 additions & 0 deletions src/api/auth/signup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { AuthResponse, SignUpRequest } from '../../types/auth';
import apiClient from '../apiClient';

export default async function signup(
data: SignUpRequest
): Promise<AuthResponse> {
const formData = new FormData();

formData.append('username', data.username);
formData.append('password', data.password);
formData.append('name', data.name);

if (data.photo) {
formData.append('photo', data.photo); // λ°±μ—”λ“œ ν•„λ“œλͺ…κ³Ό 'photo' μΌμΉ˜μ‹œν‚€κΈ°
}

const response = await apiClient.post<AuthResponse>('/auth/signup', formData);
return response.data;
}
9 changes: 9 additions & 0 deletions src/api/auth/social.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { AuthResponse, SocialLoginRequest } from '../../types/auth';
import apiClient from '../apiClient';

export default async function social(
data: SocialLoginRequest
): Promise<AuthResponse> {
const response = await apiClient.post<AuthResponse>('/auth/social', data);
return response.data;
}
12 changes: 12 additions & 0 deletions src/constants/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// .envμ—μ„œ μ •μ˜ν•œ κΈ°λ³Έ λ¦¬λ‹€μ΄λ ‰νŠΈ μ£Όμ†Œ
const REDIRECT_URI_BASE = import.meta.env.VITE_REDIRECT_URI;

// ꡬ글 인증 URL
export const GOOGLE_AUTH_URL = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${
import.meta.env.VITE_GOOGLE_CLIENT_ID
}&redirect_uri=${REDIRECT_URI_BASE}/google&response_type=code&scope=openid email profile`;

// 카카였 인증 URL
export const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${
import.meta.env.VITE_KAKAO_CLIENT_ID
}&redirect_uri=${REDIRECT_URI_BASE}/kakao&response_type=code`;
85 changes: 85 additions & 0 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useNavigate } from 'react-router';
import loginApi from '../api/auth/login';
import getMeApi from '../api/auth/me';
import signupApi from '../api/auth/signup';
import socialApi from '../api/auth/social';
import type {
LoginRequest,
SignUpRequest,
SocialLoginRequest,
} from '../types/auth';
import useAuthStore from './useAuthStore';

export default function useAuth() {
const navigate = useNavigate();

const { user, isLoggedIn, login, logout, updateUser } = useAuthStore(
(state) => state
);

// 1. 이메일 둜그인 둜직
const handleLogin = async (data: LoginRequest) => {
try {
const { user, token } = await loginApi(data);
login(user, token); // Zustand μŠ€ν† μ–΄ μ—…λ°μ΄νŠΈ
navigate('/'); // 메인 νŽ˜μ΄μ§€λ‘œ 이동
} catch (error) {
console.error('Login failed:', error);
alert('아이디 λ˜λŠ” λΉ„λ°€λ²ˆν˜Έλ₯Ό ν™•μΈν•΄μ£Όμ„Έμš”.');
}
};

// 2. 이메일 νšŒμ›κ°€μž… 둜직
const handleSignUp = async (data: SignUpRequest) => {
try {
const { user, token } = await signupApi(data);
login(user, token);
navigate('/');
} catch (error) {
console.error('Signup failed:', error);
alert('νšŒμ›κ°€μž… 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.');
}
};

// 3. μ†Œμ…œ 둜그인 둜직
const handleSocialLogin = async (data: SocialLoginRequest) => {
try {
const { user, token } = await socialApi(data);
login(user, token);
navigate('/');
} catch (error) {
console.error('Social login failed:', error);
alert('μ†Œμ…œ λ‘œκ·ΈμΈμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.');
}
};

// 4. λ‚΄ 정보 동기화
const refreshUser = async () => {
if (!isLoggedIn) return;
try {
const userData = await getMeApi();
// μœ μ € 정보와 κ·Έλ£Ή 정보 등이 λ‹΄κΈ΄ λ°μ΄ν„°λ‘œ μŠ€ν† μ–΄ κ°±μ‹ 
updateUser(userData);
} catch (error) {
console.error('Refresh user failed:', error);
handleLogout(); // 토큰이 μœ νš¨ν•˜μ§€ μ•ŠμœΌλ©΄ λ‘œκ·Έμ•„μ›ƒ 처리
alert('μœ μ € 정보 동기화 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.');
}
};

// 5. λ‘œκ·Έμ•„μ›ƒ 둜직
const handleLogout = () => {
logout(); // Zustand μƒνƒœ μ΄ˆκΈ°ν™”
navigate('/login');
};

return {
user,
isLoggedIn,
handleLogin,
handleSignUp,
handleSocialLogin,
refreshUser,
handleLogout,
};
}
34 changes: 34 additions & 0 deletions src/hooks/useAuthStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '../types/auth';

interface AuthState {
user: User | null;
token: string | null;
isLoggedIn: boolean;
// 둜그인 μ‹œ μœ μ € 정보와 토큰을 ν•¨κ»˜ μ €μž₯
login: (user: User, token: string) => void;
// λ‘œκ·Έμ•„μ›ƒ μ‹œ μƒνƒœ μ΄ˆκΈ°ν™”
logout: () => void;
// μœ μ € μ •λ³΄λ§Œ μ—…λ°μ΄νŠΈν•˜λŠ” μ•‘μ…˜
updateUser: (user: User) => void;
}

const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isLoggedIn: false,

login: (user, token) => set({ user, token, isLoggedIn: true }),
logout: () => set({ user: null, token: null, isLoggedIn: false }),
updateUser: (user) => set({ user }),
}),
{
name: 'auth-storage', // 둜컬 μŠ€ν† λ¦¬μ§€μ— μ €μž₯될 ν‚€ 이름
}
)
);

export default useAuthStore;
2 changes: 2 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import RootLayout from './layouts/RootLayout';
import Home from './routes/Home';
import Login from './routes/Login';
import Register from './routes/Register';
import SocialCallback from './routes/SocialCallback';

export const router = createBrowserRouter([
{
Expand All @@ -12,6 +13,7 @@ export const router = createBrowserRouter([
{ index: true, Component: Home },
{ path: 'login', Component: Login },
{ path: 'register', Component: Register },
{ path: 'auth/callback/:provider', Component: SocialCallback },
],
},
]);
121 changes: 115 additions & 6 deletions src/routes/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,123 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { GOOGLE_AUTH_URL, KAKAO_AUTH_URL } from '../constants/auth';
import useAuth from '../hooks/useAuth';

export default function Login() {
const [count, setCount] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { handleLogin } = useAuth();

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleLogin({ username, password });
};

return (
<div>
<p>Hello, Login Page!</p>
<button onClick={() => setCount((count) => count + 1)}>
Count is {count}
</button>
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white p-10 rounded-xl shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
λͺ¨μ΄μƒ€ 둜그인
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
계정이 μ—†μœΌμ‹ κ°€μš”?{' '}
<Link
to="/register"
className="font-medium text-blue-600 hover:text-blue-500 transition-all"
>
νšŒμ›κ°€μž…ν•˜λŸ¬ κ°€κΈ°
</Link>
</p>
</div>

<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="이메일 (아이디)"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<input
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="λΉ„λ°€λ²ˆν˜Έ"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>

<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
λ‘œκ·ΈμΈν•˜κΈ°
</button>
</div>
</form>

<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
λ˜λŠ” μ†Œμ…œ κ³„μ •μœΌλ‘œ 둜그인
</span>
</div>
</div>

<div className="flex justify-center gap-4">
<a
href={GOOGLE_AUTH_URL}
className="w-12 h-12 flex items-center justify-center border border-gray-300 rounded-full hover:bg-gray-50 transition-all shadow-sm"
aria-label="Google 둜그인"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-1 .67-2.28 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
</a>

<a
href={KAKAO_AUTH_URL}
className="w-12 h-12 flex items-center justify-center bg-[#FEE500] rounded-full hover:bg-[#FDD835] transition-all shadow-sm"
aria-label="카카였 둜그인"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M12 3C7.02944 3 3 6.13401 3 10C3 12.5 4.5 14.5 7 15.5L6 19L10 16.5C10.5 16.8 11.2 17 12 17C16.9706 17 21 13.866 21 10C21 6.13401 16.9706 3 12 3Z"
fill="#3A1D1D"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
);
}
Loading