diff --git a/backend/app/auth/auth_controller.py b/backend/app/auth/auth_controller.py index 15c3474..93414b1 100644 --- a/backend/app/auth/auth_controller.py +++ b/backend/app/auth/auth_controller.py @@ -50,6 +50,17 @@ class RefreshTokenRequest(BaseModel): refresh_token: str +class SignupRequest(BaseModel): + username: str + password: str + name: str + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + phone: Optional[str] = None + reason: Optional[str] = None # 가입 사유 + + class LoginResponse(BaseModel): success: bool access_token: str diff --git a/backend/app/auth/signup_routes.py b/backend/app/auth/signup_routes.py new file mode 100644 index 0000000..77874df --- /dev/null +++ b/backend/app/auth/signup_routes.py @@ -0,0 +1,296 @@ +""" +회원가입 요청 및 관리자 승인 API +""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import text +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +from ..database import get_db +from .auth_service import AuthService +from .models import UserRepository +from .middleware import get_current_user +from ..utils.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter(prefix="/auth", tags=["signup"]) + + +class SignupRequest(BaseModel): + username: str + password: str + name: str + email: Optional[str] = None + department: Optional[str] = None + position: Optional[str] = None + phone: Optional[str] = None + reason: Optional[str] = None # 가입 사유 + + +@router.post("/signup-request") +async def signup_request( + signup_data: SignupRequest, + db: Session = Depends(get_db) +): + """ + 회원가입 요청 (관리자 승인 대기) + + Args: + signup_data: 가입 신청 정보 + + Returns: + dict: 요청 결과 + """ + try: + # 중복 사용자명 확인 + user_repo = UserRepository(db) + existing_user = user_repo.get_user_by_username(signup_data.username) + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="이미 존재하는 사용자명입니다" + ) + + # 중복 이메일 확인 + if signup_data.email: + check_email = text("SELECT id FROM users WHERE email = :email") + existing_email = db.execute(check_email, {"email": signup_data.email}).fetchone() + + if existing_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="이미 등록된 이메일입니다" + ) + + # 비밀번호 해싱 + auth_service = AuthService() + hashed_password = auth_service.hash_password(signup_data.password) + + # 승인 대기 상태로 사용자 생성 + new_user = user_repo.create_user( + username=signup_data.username, + hashed_password=hashed_password, + name=signup_data.name, + email=signup_data.email, + access_level='pending', # 승인 대기 + department=signup_data.department, + position=signup_data.position, + phone=signup_data.phone, + role='user', + is_active=False # 비활성 상태 + ) + + # 가입 사유 저장 (notes 컬럼 활용) + if signup_data.reason: + update_notes = text("UPDATE users SET notes = :reason WHERE id = :user_id") + db.execute(update_notes, {"reason": signup_data.reason, "user_id": new_user.id}) + db.commit() + + return { + "success": True, + "message": "회원가입 요청이 전송되었습니다. 관리자 승인 후 이용 가능합니다.", + "user_id": new_user.id, + "username": new_user.username, + "status": "pending_approval" + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"회원가입 요청 실패: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"회원가입 요청 처리 중 오류가 발생했습니다: {str(e)}" + ) + + +@router.get("/signup-requests") +async def get_signup_requests( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 회원가입 요청 목록 조회 (관리자 전용) + + Returns: + dict: 승인 대기 중인 사용자 목록 + """ + try: + # 관리자 권한 확인 + if current_user.get('role') not in ['admin', 'system']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="관리자만 접근 가능합니다" + ) + + # 승인 대기 중인 사용자 조회 + query = text(""" + SELECT + id, username, name, email, department, position, + phone, notes, created_at + FROM users + WHERE access_level = 'pending' AND is_active = FALSE + ORDER BY created_at DESC + """) + + results = db.execute(query).fetchall() + + pending_users = [] + for row in results: + pending_users.append({ + "id": row.id, + "username": row.username, + "name": row.name, + "email": row.email, + "department": row.department, + "position": row.position, + "phone": row.phone, + "reason": row.notes, + "requested_at": row.created_at.isoformat() if row.created_at else None + }) + + return { + "success": True, + "requests": pending_users, + "count": len(pending_users) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"가입 요청 목록 조회 실패: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"가입 요청 목록 조회 실패: {str(e)}" + ) + + +@router.post("/approve-signup/{user_id}") +async def approve_signup( + user_id: int, + access_level: str = 'worker', + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 회원가입 승인 (관리자 전용) + + Args: + user_id: 승인할 사용자 ID + access_level: 부여할 접근 레벨 (worker, manager, admin) + + Returns: + dict: 승인 결과 + """ + try: + # 관리자 권한 확인 + if current_user.get('role') not in ['admin', 'system']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="관리자만 접근 가능합니다" + ) + + # 사용자 활성화 및 접근 레벨 설정 + update_query = text(""" + UPDATE users + SET is_active = TRUE, + access_level = :access_level, + updated_at = CURRENT_TIMESTAMP + WHERE id = :user_id AND access_level = 'pending' + RETURNING id, username, name + """) + + result = db.execute(update_query, { + "user_id": user_id, + "access_level": access_level + }).fetchone() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="승인 대기 중인 사용자를 찾을 수 없습니다" + ) + + db.commit() + + return { + "success": True, + "message": f"{result.name}님의 가입이 승인되었습니다", + "user": { + "id": result.id, + "username": result.username, + "name": result.name, + "access_level": access_level + } + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"가입 승인 실패: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"가입 승인 처리 중 오류가 발생했습니다: {str(e)}" + ) + + +@router.delete("/reject-signup/{user_id}") +async def reject_signup( + user_id: int, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 회원가입 거부 (관리자 전용) + + Args: + user_id: 거부할 사용자 ID + + Returns: + dict: 거부 결과 + """ + try: + # 관리자 권한 확인 + if current_user.get('role') not in ['admin', 'system']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="관리자만 접근 가능합니다" + ) + + # 승인 대기 사용자 삭제 + delete_query = text(""" + DELETE FROM users + WHERE id = :user_id AND access_level = 'pending' + RETURNING username, name + """) + + result = db.execute(delete_query, {"user_id": user_id}).fetchone() + + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="승인 대기 중인 사용자를 찾을 수 없습니다" + ) + + db.commit() + + return { + "success": True, + "message": f"{result.name}님의 가입 요청이 거부되었습니다" + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"가입 거부 실패: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"가입 거부 처리 중 오류가 발생했습니다: {str(e)}" + ) + diff --git a/backend/app/main.py b/backend/app/main.py index a681ef8..d514e54 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -109,10 +109,13 @@ logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용 # 인증 API 라우터 등록 try: from .auth import auth_router, setup_router + from .auth.signup_routes import router as signup_router app.include_router(auth_router, prefix="/auth", tags=["authentication"]) app.include_router(setup_router, prefix="/setup", tags=["system-setup"]) + app.include_router(signup_router, tags=["signup"]) logger.info("인증 API 라우터 등록 완료") logger.info("시스템 설정 API 라우터 등록 완료") + logger.info("회원가입 API 라우터 등록 완료") except ImportError as e: logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}") diff --git a/frontend/src/SimpleLogin.jsx b/frontend/src/SimpleLogin.jsx index 149d621..de626cf 100644 --- a/frontend/src/SimpleLogin.jsx +++ b/frontend/src/SimpleLogin.jsx @@ -9,6 +9,18 @@ const SimpleLogin = ({ onLoginSuccess }) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [showSignup, setShowSignup] = useState(false); + const [signupData, setSignupData] = useState({ + username: '', + password: '', + confirmPassword: '', + name: '', + email: '', + department: '', + position: '', + phone: '', + reason: '' + }); const handleChange = (e) => { const { name, value } = e.target; @@ -19,6 +31,73 @@ const SimpleLogin = ({ onLoginSuccess }) => { if (error) setError(''); }; + const handleSignupChange = (e) => { + const { name, value } = e.target; + setSignupData(prev => ({ + ...prev, + [name]: value + })); + if (error) setError(''); + }; + + const handleSignupSubmit = async (e) => { + e.preventDefault(); + + // 유효성 검사 + if (!signupData.username || !signupData.password || !signupData.name) { + setError('필수 항목을 모두 입력해주세요.'); + return; + } + + if (signupData.password !== signupData.confirmPassword) { + setError('비밀번호가 일치하지 않습니다.'); + return; + } + + if (signupData.password.length < 6) { + setError('비밀번호는 최소 6자 이상이어야 합니다.'); + return; + } + + setIsLoading(true); + setError(''); + + try { + const response = await api.post('/auth/signup-request', { + username: signupData.username, + password: signupData.password, + name: signupData.name, + email: signupData.email || null, + department: signupData.department || null, + position: signupData.position || null, + phone: signupData.phone || null, + reason: signupData.reason || null + }); + + if (response.data.success) { + setSuccess('회원가입 요청이 전송되었습니다. 관리자 승인 후 이용 가능합니다.'); + setShowSignup(false); + // 폼 초기화 + setSignupData({ + username: '', + password: '', + confirmPassword: '', + name: '', + email: '', + department: '', + position: '', + phone: '', + reason: '' + }); + } + } catch (err) { + const errorMsg = err.response?.data?.detail || '회원가입 요청 중 오류가 발생했습니다.'; + setError(errorMsg); + } finally { + setIsLoading(false); + } + }; + const handleSubmit = async (e) => { e.preventDefault(); @@ -175,6 +254,55 @@ const SimpleLogin = ({ onLoginSuccess }) => { {isLoading ? '로그인 중...' : '로그인'} + + {/* 회원가입 버튼 */} +