회원가입 신청 기능 완성 (간소화)
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

백엔드:
- signup_routes.py 신규 생성
- POST /auth/signup-request: 회원가입 신청
- GET /auth/signup-requests: 승인 대기 목록 (관리자)
- POST /auth/approve-signup/{id}: 승인 (관리자)
- DELETE /auth/reject-signup/{id}: 거부 (관리자)
- main.py에 signup_router 등록

프론트엔드:
- SimpleLogin에 회원가입 폼 추가
- 필수 항목만: 사용자명, 비밀번호, 비밀번호 확인, 이름
- 간단하고 깔끔한 UI
- 비밀번호 일치 검사 및 최소 길이 검사
- 제출 후 승인 대기 안내 메시지
This commit is contained in:
Hyungi Ahn
2025-10-14 07:28:06 +09:00
parent dfb6c7e8a4
commit e14f8b69c7
4 changed files with 438 additions and 0 deletions

View File

@@ -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

View File

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

View File

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

View File

@@ -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 ? '로그인 중...' : '로그인'}
</button>
</form>
{/* 회원가입 버튼 */}
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<button
type="button"
onClick={() => {
setShowSignup(!showSignup);
setError('');
setSuccess('');
}}
style={{
width: '100%',
padding: '14px',
backgroundColor: showSignup ? '#6b7280' : '#10b981',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer',
transition: 'background-color 0.3s ease'
}}
>
{showSignup ? '✕ 닫기' : ' 회원가입 신청'}
</button>
</div>
{/* 회원가입 폼 */}
{showSignup && (
<div style={{
marginTop: '20px',
padding: '20px',
background: '#f0fdf4',
border: '2px solid #10b981',
borderRadius: '12px',
maxHeight: '500px',
overflowY: 'auto'
}}>
<form onSubmit={handleSignupSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<input type="text" name="username" value={signupData.username} onChange={handleSignupChange} placeholder="사용자명*" required style={{ padding: '12px', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '14px' }} />
<input type="password" name="password" value={signupData.password} onChange={handleSignupChange} placeholder="비밀번호 (최소 6자)*" required style={{ padding: '12px', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '14px' }} />
<input type="password" name="confirmPassword" value={signupData.confirmPassword} onChange={handleSignupChange} placeholder="비밀번호 확인*" required style={{ padding: '12px', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '14px' }} />
<input type="text" name="name" value={signupData.name} onChange={handleSignupChange} placeholder="이름*" required style={{ padding: '12px', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '14px' }} />
<button type="submit" disabled={isLoading} style={{ padding: '14px', background: isLoading ? '#9ca3af' : '#10b981', color: 'white', border: 'none', borderRadius: '8px', fontSize: '16px', fontWeight: '600', cursor: isLoading ? 'not-allowed' : 'pointer', marginTop: '8px' }}>
{isLoading ? '처리 중...' : '✓ 회원가입 신청'}
</button>
</form>
</div>
)}
{error && (
<div style={{