✨ 회원가입 신청 기능 완성 (간소화)
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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:
@@ -50,6 +50,17 @@ class RefreshTokenRequest(BaseModel):
|
|||||||
refresh_token: str
|
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):
|
class LoginResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|||||||
296
backend/app/auth/signup_routes.py
Normal file
296
backend/app/auth/signup_routes.py
Normal 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)}"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -109,10 +109,13 @@ logger.info("파일 관리 API 라우터 비활성화됨 (files 라우터 사용
|
|||||||
# 인증 API 라우터 등록
|
# 인증 API 라우터 등록
|
||||||
try:
|
try:
|
||||||
from .auth import auth_router, setup_router
|
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(auth_router, prefix="/auth", tags=["authentication"])
|
||||||
app.include_router(setup_router, prefix="/setup", tags=["system-setup"])
|
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 라우터 등록 완료")
|
logger.info("시스템 설정 API 라우터 등록 완료")
|
||||||
|
logger.info("회원가입 API 라우터 등록 완료")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
|
logger.warning(f"인증 라우터를 찾을 수 없습니다: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ const SimpleLogin = ({ onLoginSuccess }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = 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 handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
@@ -19,6 +31,73 @@ const SimpleLogin = ({ onLoginSuccess }) => {
|
|||||||
if (error) setError('');
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -175,6 +254,55 @@ const SimpleLogin = ({ onLoginSuccess }) => {
|
|||||||
{isLoading ? '로그인 중...' : '로그인'}
|
{isLoading ? '로그인 중...' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user