diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 374015ee..2d574cc7 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -384,4 +384,71 @@ export class AuthController { }); } } + + /** + * POST /api/auth/signup + * 회원가입 API + */ + static async signup(req: Request, res: Response): Promise { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = req.body; + + logger.info(`=== 회원가입 API 호출 ===`); + logger.info(`userId: ${userId}`); + + // 입력값 검증 + if (!userId || !password || !userName || !phoneNumber || !licenseNumber || !vehicleNumber) { + res.status(400).json({ + success: false, + message: "모든 필수 항목을 입력해주세요.", + error: { + code: "INVALID_INPUT", + details: "필수 입력값이 누락되었습니다.", + }, + }); + return; + } + + // 회원가입 처리 + const signupResult = await AuthService.signupUser({ + userId, + password, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + }); + + if (signupResult.success) { + logger.info(`회원가입 성공: ${userId}`); + res.status(201).json({ + success: true, + message: "회원가입이 완료되었습니다.", + data: { + userId, + }, + }); + } else { + logger.warn(`회원가입 실패: ${userId} - ${signupResult.message}`); + res.status(400).json({ + success: false, + message: signupResult.message || "회원가입에 실패했습니다.", + error: { + code: "SIGNUP_FAILED", + details: signupResult.message, + }, + }); + } + } catch (error: any) { + logger.error("회원가입 오류:", error); + res.status(500).json({ + success: false, + message: "회원가입 처리 중 오류가 발생했습니다.", + error: { + code: "SIGNUP_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }, + }); + } + } } diff --git a/backend-node/src/routes/authRoutes.ts b/backend-node/src/routes/authRoutes.ts index 29bc7944..357bfc8e 100644 --- a/backend-node/src/routes/authRoutes.ts +++ b/backend-node/src/routes/authRoutes.ts @@ -41,4 +41,10 @@ router.post("/logout", AuthController.logout); */ router.post("/refresh", AuthController.refreshToken); +/** + * POST /api/auth/signup + * 회원가입 API + */ +router.post("/signup", AuthController.signup); + export default router; diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 11e34576..1bf4dd40 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -342,4 +342,77 @@ export class AuthService { ); } } + + /** + * 회원가입 처리 + */ + static async signupUser(data: { + userId: string; + password: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; + }): Promise<{ success: boolean; message?: string }> { + try { + const { userId, password, userName, phoneNumber, licenseNumber, vehicleNumber } = data; + + // 1. 중복 사용자 확인 + const existingUser = await query( + `SELECT user_id FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (existingUser.length > 0) { + return { + success: false, + message: "이미 존재하는 아이디입니다.", + }; + } + + // 2. 비밀번호 암호화 + const bcrypt = require("bcryptjs"); + const hashedPassword = await bcrypt.hash(password, 10); + + // 3. 사용자 정보 저장 + await query( + `INSERT INTO user_info ( + user_id, + user_password, + user_name, + cell_phone, + license_number, + vehicle_number, + company_code, + user_type, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`, + [ + userId, + hashedPassword, + userName, + phoneNumber, + licenseNumber, + vehicleNumber, + "*", // 기본 회사 코드 + null, // user_type: null + "active", // status: active + ] + ); + + logger.info(`회원가입 성공: ${userId}`); + + return { + success: true, + message: "회원가입이 완료되었습니다.", + }; + } catch (error: any) { + logger.error("회원가입 오류:", error); + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다.", + }; + } + } } diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..ebfed844 --- /dev/null +++ b/frontend/app/(auth)/signup/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useSignup } from "@/hooks/useSignup"; +import { LoginHeader } from "@/components/auth/LoginHeader"; +import { SignupForm } from "@/components/auth/SignupForm"; +import { LoginFooter } from "@/components/auth/LoginFooter"; + +/** + * 회원가입 페이지 컴포넌트 + */ +export default function SignupPage() { + const { + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + handleInputChange, + handleBlur, + handleSignup, + togglePasswordVisibility, + } = useSignup(); + + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index dda3736f..9d82895a 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Eye, EyeOff, Loader2 } from "lucide-react"; import { LoginFormData } from "@/types/auth"; import { ErrorMessage } from "./ErrorMessage"; +import { useRouter } from "next/navigation"; interface LoginFormProps { formData: LoginFormData; @@ -28,6 +29,8 @@ export function LoginForm({ onSubmit, onTogglePassword, }: LoginFormProps) { + const router = useRouter(); + return ( @@ -97,6 +100,17 @@ export function LoginForm({ "로그인" )} + + {/* 회원가입 버튼 */} + diff --git a/frontend/components/auth/SignupForm.tsx b/frontend/components/auth/SignupForm.tsx new file mode 100644 index 00000000..e7c8f267 --- /dev/null +++ b/frontend/components/auth/SignupForm.tsx @@ -0,0 +1,244 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react"; +import { SignupFormData } from "@/types/auth"; +import { ErrorMessage } from "./ErrorMessage"; +import { useRouter } from "next/navigation"; + +interface SignupFormProps { + formData: SignupFormData; + isLoading: boolean; + error: string; + showPassword: boolean; + validationErrors: Record; + touchedFields: Record; + isFormValid: boolean; + onInputChange: (e: React.ChangeEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onTogglePassword: () => void; +} + +/** + * 회원가입 폼 컴포넌트 + */ +export function SignupForm({ + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + onInputChange, + onBlur, + onSubmit, + onTogglePassword, +}: SignupFormProps) { + const router = useRouter(); + + return ( + + + 회원가입 + 새로운 계정을 만들어보세요 + + + + +
+ {/* 아이디 */} +
+ + + {touchedFields?.userId && validationErrors.userId && ( +

{validationErrors.userId}

+ )} +
+ + {/* 비밀번호 */} +
+ +
+ + +
+ {touchedFields?.password && validationErrors.password && ( +

{validationErrors.password}

+ )} +
+ + {/* 비밀번호 확인 */} +
+ +
+ + +
+ {touchedFields?.passwordConfirm && validationErrors.passwordConfirm && ( +

{validationErrors.passwordConfirm}

+ )} +
+ + {/* 이름 */} +
+ + + {touchedFields?.userName && validationErrors.userName && ( +

{validationErrors.userName}

+ )} +
+ + {/* 연락처 */} +
+ + + {touchedFields?.phoneNumber && validationErrors.phoneNumber && ( +

{validationErrors.phoneNumber}

+ )} +
+ + {/* 면허번호 */} +
+ + + {touchedFields?.licenseNumber && validationErrors.licenseNumber && ( +

{validationErrors.licenseNumber}

+ )} +
+ + {/* 차량번호 */} +
+ + + {touchedFields?.vehicleNumber && validationErrors.vehicleNumber && ( +

{validationErrors.vehicleNumber}

+ )} +
+ + {/* 회원가입 버튼 */} + + + {/* 로그인으로 돌아가기 버튼 */} + +
+
+
+ ); +} + diff --git a/frontend/hooks/useSignup.ts b/frontend/hooks/useSignup.ts new file mode 100644 index 00000000..6327320b --- /dev/null +++ b/frontend/hooks/useSignup.ts @@ -0,0 +1,235 @@ +import { useState, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { SignupFormData } from "@/types/auth"; +import { signupUser } from "@/lib/api/auth"; +import { useToast } from "@/hooks/use-toast"; + +/** + * 유효성 검사 함수들 + */ +const validators = { + // 연락처: 010-1234-5678 형식 + phoneNumber: (value: string): string | null => { + const phoneRegex = /^01[0-9]-\d{3,4}-\d{4}$/; + if (!value) return "연락처를 입력해주세요"; + if (!phoneRegex.test(value)) return "올바른 연락처 형식이 아닙니다 (예: 010-1234-5678)"; + return null; + }, + + // 면허번호: 12-34-567890-12 형식 + licenseNumber: (value: string): string | null => { + const licenseRegex = /^\d{2}-\d{2}-\d{6}-\d{2}$/; + if (!value) return "면허번호를 입력해주세요"; + if (!licenseRegex.test(value)) return "올바른 면허번호 형식이 아닙니다 (예: 12-34-567890-12)"; + return null; + }, + + // 차량번호: 12가1234 형식 + vehicleNumber: (value: string): string | null => { + const vehicleRegex = /^\d{2,3}[가-힣]\d{4}$/; + if (!value) return "차량번호를 입력해주세요"; + if (!vehicleRegex.test(value)) return "올바른 차량번호 형식이 아닙니다 (예: 12가1234)"; + return null; + }, + + // 아이디: 4자 이상 + userId: (value: string): string | null => { + if (!value) return "아이디를 입력해주세요"; + if (value.length < 4) return "아이디는 4자 이상이어야 합니다"; + return null; + }, + + // 비밀번호: 6자 이상 + password: (value: string): string | null => { + if (!value) return "비밀번호를 입력해주세요"; + if (value.length < 6) return "비밀번호는 6자 이상이어야 합니다"; + return null; + }, + + // 비밀번호 확인: password와 일치해야 함 + passwordConfirm: (value: string, password?: string): string | null => { + if (!value) return "비밀번호 확인을 입력해주세요"; + if (password && value !== password) return "비밀번호가 일치하지 않습니다"; + return null; + }, + + // 이름: 2자 이상 + userName: (value: string): string | null => { + if (!value) return "이름을 입력해주세요"; + if (value.length < 2) return "이름은 2자 이상이어야 합니다"; + return null; + }, +}; + +/** + * 회원가입 커스텀 훅 + */ +export function useSignup() { + const router = useRouter(); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const [formData, setFormData] = useState({ + userId: "", + password: "", + passwordConfirm: "", + userName: "", + phoneNumber: "", + licenseNumber: "", + vehicleNumber: "", + }); + + const [validationErrors, setValidationErrors] = useState>({}); + const [touchedFields, setTouchedFields] = useState>({}); + const [isFormValid, setIsFormValid] = useState(false); + + // 유효성 검사 실행 + const validateField = useCallback((name: string, value: string, password?: string) => { + const validator = validators[name as keyof typeof validators]; + if (validator) { + // passwordConfirm 검증 시 password도 함께 전달 + if (name === "passwordConfirm") { + return validator(value, password); + } + return validator(value); + } + return null; + }, []); + + // 전체 폼 유효성 검사 + const validateForm = useCallback(() => { + const errors: Record = {}; + let hasErrors = false; + + Object.keys(formData).forEach((key) => { + const error = validateField(key, formData[key as keyof SignupFormData], formData.password); + if (error) { + errors[key] = error; + hasErrors = true; + } + }); + + setValidationErrors(errors); + setIsFormValid(!hasErrors && Object.values(formData).every((value) => value !== "")); + }, [formData, validateField]); + + // 입력 변경 핸들러 + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + + // touched된 필드만 실시간 유효성 검사 + if (touchedFields[name]) { + const fieldError = validateField(name, value, newFormData.password); + setValidationErrors((prev) => ({ + ...prev, + [name]: fieldError || "", + })); + } + + // password가 변경되면 passwordConfirm도 다시 검증 + if (name === "password" && touchedFields.passwordConfirm) { + const confirmError = validateField("passwordConfirm", newFormData.passwordConfirm, value); + setValidationErrors((prev) => ({ + ...prev, + passwordConfirm: confirmError || "", + })); + } + + setError(""); + }, + [validateField, touchedFields, formData], + ); + + // 포커스 아웃 핸들러 (Blur) + const handleBlur = useCallback( + (e: React.FocusEvent) => { + const { name, value } = e.target; + + // 필드를 touched로 표시 + setTouchedFields((prev) => ({ ...prev, [name]: true })); + + // 유효성 검사 실행 + const fieldError = validateField(name, value, formData.password); + setValidationErrors((prev) => ({ + ...prev, + [name]: fieldError || "", + })); + }, + [validateField, formData.password], + ); + + // 회원가입 제출 + const handleSignup = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // 모든 필드를 touched로 표시 (제출 시 모든 에러 표시) + const allTouched = Object.keys(formData).reduce((acc, key) => ({ ...acc, [key]: true }), {}); + setTouchedFields(allTouched); + + // 최종 유효성 검사 + validateForm(); + + if (!isFormValid) { + setError("입력 정보를 다시 확인해주세요"); + return; + } + + setIsLoading(true); + + try { + const response = await signupUser(formData); + + if (response.success) { + toast({ + title: "회원가입 성공", + description: "로그인 페이지로 이동합니다", + }); + + // 1초 후 로그인 페이지로 이동 + setTimeout(() => { + router.push("/login"); + }, 1000); + } else { + setError(response.message || "회원가입에 실패했습니다"); + } + } catch (err: any) { + console.error("회원가입 오류:", err); + setError(err.message || "회원가입 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }, + [formData, isFormValid, router, toast, validateForm], + ); + + // 비밀번호 표시 토글 + const togglePasswordVisibility = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + + // 폼 데이터 변경 시 유효성 검사 실행 + useEffect(() => { + validateForm(); + }, [formData, validateForm]); + + return { + formData, + isLoading, + error, + showPassword, + validationErrors, + touchedFields, + isFormValid, + handleInputChange, + handleBlur, + handleSignup, + togglePasswordVisibility, + }; +} diff --git a/frontend/lib/api/auth.ts b/frontend/lib/api/auth.ts new file mode 100644 index 00000000..c3f701e0 --- /dev/null +++ b/frontend/lib/api/auth.ts @@ -0,0 +1,21 @@ +import { apiClient } from "./client"; +import { SignupFormData, SignupResponse } from "@/types/auth"; + +/** + * 회원가입 API + */ +export async function signupUser(data: SignupFormData): Promise { + try { + const response = await apiClient.post("/auth/signup", data); + return response.data; + } catch (error: any) { + if (error.response?.data) { + return error.response.data; + } + return { + success: false, + message: error.message || "회원가입 중 오류가 발생했습니다", + }; + } +} + diff --git a/frontend/types/auth.ts b/frontend/types/auth.ts index cd8e65b6..f864df5b 100644 --- a/frontend/types/auth.ts +++ b/frontend/types/auth.ts @@ -7,6 +7,16 @@ export interface LoginFormData { password: string; } +export interface SignupFormData { + userId: string; + password: string; + passwordConfirm: string; + userName: string; + phoneNumber: string; + licenseNumber: string; + vehicleNumber: string; +} + export interface LoginResponse { success: boolean; message?: string; @@ -18,6 +28,14 @@ export interface LoginResponse { errorCode?: string; } +export interface SignupResponse { + success: boolean; + message?: string; + data?: { + userId: string; + }; +} + export interface AuthStatus { isLoggedIn: boolean; isAdmin?: boolean;