Merge branch 'dev' into main — DB 계정 회원가입/로그인

This commit is contained in:
2026-09-20 14:48:08 +09:00
6 changed files with 412 additions and 95 deletions

View File

@@ -19,11 +19,9 @@ BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
SECRET_KEY=your-secret-key-here
# 대시보드 로그인 계정 (ADMIN_PASSWORD_HASH 우선, 없으면 ADMIN_PASSWORD 평문. 둘 다 비면 로그인 불가)
# 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())"
ADMIN_USERNAME=admin
ADMIN_PASSWORD=
ADMIN_PASSWORD_HASH=
# 대시보드 로그인 — 계정은 DB(users)에 저장. 첫 가입자 = 관리자(자동 승인), 이후 가입자는 관리자 승인 필요
SIGNUP_ENABLED=true
SIGNUP_AUTO_APPROVE=false
SESSION_TOKEN_EXPIRE_HOURS=24
# ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,40 @@
"""add users table (dashboard login accounts)
Revision ID: 0008
Revises: 0007
Create Date: 2026-09-20
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
exists = conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name='users'"
)).fetchone()
if exists:
return # create_all 로 이미 생성됨
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(32), nullable=False),
sa.Column("password_hash", sa.String(128), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_users_username", "users", ["username"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_username", table_name="users")
op.drop_table("users")

View File

@@ -1,60 +1,117 @@
"""대시보드 로그인 엔드포인트 (아이디/비밀번호 → 세션 토큰).
"""대시보드 계정 인증 엔드포인트 (DB users 테이블 기반 회원가입/로그인).
POST /api/v1/auth/login {username, password} → {token, expires_at, username}
POST /api/v1/auth/register {username, password} → 계정 생성 (201)
POST /api/v1/auth/login {username, password} → {token, expires_at, username, is_admin}
GET /api/v1/auth/me (X-API-Key: 세션 토큰 또는 API 키) → 현재 인증 주체
GET /api/v1/auth/users (관리자) 계정 목록
POST /api/v1/auth/users/{id}/approve (관리자) 가입 승인
POST /api/v1/auth/users/{id}/revoke (관리자) 비활성화
- 자격 증명은 .env 의 ADMIN_USERNAME + ADMIN_PASSWORD_HASH(bcrypt) 또는 ADMIN_PASSWORD(평문)
- 발급 토큰은 기존 X-API-Key 헤더로 전송 → security.require_api_key 가 세션 토큰도 수용
- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금(인메모리)
정책
- 첫 가입자는 자동으로 관리자 + 활성. 이후 가입자는 SIGNUP_AUTO_APPROVE=False 면
비활성 상태로 생성되어 관리자 승인 전까지 로그인 불가(403).
- 비밀번호는 bcrypt 해시로만 저장. 발급 토큰은 기존 X-API-Key 헤더로 전송되며
security.require_api_key 가 세션 토큰도 수용한다. API 키(SECRET_KEY)는 관리자로 간주.
- 무차별 대입 완화: IP당 연속 실패 LOGIN_MAX_FAILURES 회 초과 시 LOGIN_LOCKOUT_SECONDS 잠금.
DB 접근
- 회원가입/로그인은 "중복 확인 → INSERT/UPDATE → 응답" 이 한 요청 안에서 원자적으로
끝나야 하므로 sync_portfolio 와 같은 예외로 엔드포인트에서 직접 세션을 사용한다
(db_worker 큐는 결과를 응답에 되돌릴 수 없음). 실시간 계층과는 무관한 경로.
"""
import asyncio
import hmac
import logging
import re
import time
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, status
import bcrypt
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import (
_get_api_key,
create_session_token,
require_api_key,
verify_session_token,
)
from app.models.models import User
logger = logging.getLogger(__name__)
router = APIRouter()
# IP별 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각)}
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$")
# IP별 로그인 실패 기록: {ip: (연속 실패 횟수, 마지막 실패 시각 monotonic)}
_failures: dict[str, tuple[int, float]] = {}
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=256)
# ── 스키마 ────────────────────────────────────────────────────────────────
class Credentials(BaseModel):
username: str = Field(..., min_length=3, max_length=32)
password: str = Field(..., min_length=8, max_length=128)
@field_validator("username")
@classmethod
def _username_format(cls, v: str) -> str:
v = v.strip()
if not _USERNAME_RE.match(v):
raise ValueError("아이디는 영문/숫자/밑줄 3~32자여야 합니다.")
return v
class LoginResponse(BaseModel):
token: str
expires_at: datetime
username: str
is_admin: bool
def _login_enabled() -> bool:
return bool(settings.ADMIN_PASSWORD_HASH or settings.ADMIN_PASSWORD)
class RegisterResponse(BaseModel):
username: str
is_active: bool
is_admin: bool
message: str
def _check_password(password: str) -> bool:
if settings.ADMIN_PASSWORD_HASH:
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용
class UserOut(BaseModel):
id: int
username: str
is_active: bool
is_admin: bool
created_at: datetime
last_login_at: datetime | None
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
def _hash_password(password: str) -> str:
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 (72바이트 제한)
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
_DUMMY_HASH = _hash_password("not-a-real-password") # 미존재 계정 타이밍 균등화용
def _check_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode()[:72], settings.ADMIN_PASSWORD_HASH.encode())
return bcrypt.checkpw(password.encode()[:72], password_hash.encode())
except ValueError:
logger.error("[Auth] ADMIN_PASSWORD_HASH 형식 오류 — bcrypt 해시가 아님")
return False
return hmac.compare_digest(password.encode(), settings.ADMIN_PASSWORD.encode())
# bcrypt 는 의도적으로 느린(≈100~300ms) CPU 연산 → 이벤트 루프를 막지 않도록 스레드로 위임
async def _hash_password_async(password: str) -> str:
return await asyncio.to_thread(_hash_password, password)
async def _check_password_async(password: str, password_hash: str) -> bool:
return await asyncio.to_thread(_check_password, password, password_hash)
def _client_ip(request: Request) -> str:
@@ -81,21 +138,69 @@ def _lockout_remaining(ip: str) -> int:
def _record_failure(ip: str) -> None:
count, _ = _failures.get(ip, (0, 0.0))
_failures[ip] = (count + 1, time.monotonic())
# 오래된 항목 정리 (메모리 누수 방지)
if len(_failures) > 1000:
if len(_failures) > 1000: # 오래된 항목 정리
cutoff = time.monotonic() - settings.LOGIN_LOCKOUT_SECONDS
for k in [k for k, (_, t) in _failures.items() if t < cutoff]:
_failures.pop(k, None)
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, request: Request):
if not _login_enabled():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="로그인이 설정되지 않았습니다. .env 에 ADMIN_PASSWORD 또는 ADMIN_PASSWORD_HASH 를 설정하세요.",
)
async def _resolve_user(credential: str, db: AsyncSession) -> User | None:
"""세션 토큰 → User. API 키면 None (호출 측에서 관리자 취급)."""
username = verify_session_token(credential)
if not username:
return None
return (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
async def require_admin(
credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)
) -> str:
"""관리자 전용 의존성. API 키(SECRET_KEY) 또는 is_admin 세션 토큰만 통과."""
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
return "api-key"
user = await _resolve_user(credential, db)
if user is None or not user.is_active or not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="관리자 권한이 필요합니다.")
return user.username
# ── 엔드포인트 ────────────────────────────────────────────────────────────
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
async def register(body: Credentials, request: Request, db: AsyncSession = Depends(get_db)):
if not settings.SIGNUP_ENABLED:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="회원가입이 비활성화되어 있습니다.")
total = (await db.execute(select(func.count()).select_from(User))).scalar() or 0
is_first = total == 0
is_active = is_first or settings.SIGNUP_AUTO_APPROVE
user = User(
username=body.username,
password_hash=await _hash_password_async(body.password),
is_active=is_active,
is_admin=is_first,
)
db.add(user)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 사용 중인 아이디입니다.")
logger.info(
f"[Auth] 회원가입 user={body.username} admin={is_first} active={is_active} ip={_client_ip(request)}"
)
if is_first:
message = "첫 계정이 관리자로 생성되었습니다. 바로 로그인할 수 있습니다."
elif is_active:
message = "가입이 완료되었습니다. 바로 로그인할 수 있습니다."
else:
message = "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다."
return RegisterResponse(username=user.username, is_active=is_active, is_admin=is_first, message=message)
@router.post("/login", response_model=LoginResponse)
async def login(body: Credentials, request: Request, db: AsyncSession = Depends(get_db)):
ip = _client_ip(request)
remaining = _lockout_remaining(ip)
if remaining > 0:
@@ -104,25 +209,68 @@ async def login(body: LoginRequest, request: Request):
detail=f"로그인 시도가 너무 많습니다. {remaining}초 후 다시 시도하세요.",
)
user_ok = hmac.compare_digest(body.username.encode(), settings.ADMIN_USERNAME.encode())
pw_ok = _check_password(body.password)
if not (user_ok and pw_ok):
user = (await db.execute(select(User).where(User.username == body.username))).scalar_one_or_none()
# 존재하지 않는 계정도 해시 검증을 수행해 응답 시간 차이로 계정 유무가 드러나지 않게 함
pw_ok = await _check_password_async(body.password, user.password_hash if user else _DUMMY_HASH)
if user is None or not pw_ok:
_record_failure(ip)
logger.warning(f"[Auth] 로그인 실패 ip={ip} user={body.username!r}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="아이디 또는 비밀번호가 올바르지 않습니다.",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 승인 대기 중인 계정입니다.",
)
_failures.pop(ip, None)
token, expires_at = create_session_token(settings.ADMIN_USERNAME)
logger.info(f"[Auth] 로그인 성공 user={settings.ADMIN_USERNAME} ip={ip}")
return LoginResponse(token=token, expires_at=expires_at, username=settings.ADMIN_USERNAME)
user.last_login_at = datetime.utcnow()
await db.commit()
token, expires_at = create_session_token(user.username)
logger.info(f"[Auth] 로그인 성공 user={user.username} ip={ip}")
return LoginResponse(token=token, expires_at=expires_at, username=user.username, is_admin=user.is_admin)
@router.get("/me")
async def me(credential: str = Depends(require_api_key)):
"""현재 자격 증명 확인 — 세션 토큰이면 username, API 키면 'api-key'."""
async def me(credential: str = Depends(require_api_key), db: AsyncSession = Depends(get_db)):
"""현재 자격 증명 확인 — 세션 토큰이면 계정 정보, API 키면 'api-key'(관리자)."""
if hmac.compare_digest(credential.encode(), _get_api_key().encode()):
return {"username": "api-key", "auth_type": "api_key"}
return {"username": verify_session_token(credential), "auth_type": "session"}
return {"username": "api-key", "auth_type": "api_key", "is_admin": True}
user = await _resolve_user(credential, db)
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="세션이 유효하지 않습니다.")
return {"username": user.username, "auth_type": "session", "is_admin": user.is_admin}
@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)])
async def list_users(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(select(User).order_by(User.id))).scalars().all()
return [UserOut.model_validate(u, from_attributes=True) for u in rows]
@router.post("/users/{user_id}/approve", response_model=UserOut)
async def approve_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.")
user.is_active = True
await db.commit()
logger.info(f"[Auth] 계정 승인 user={user.username} by={admin}")
return UserOut.model_validate(user, from_attributes=True)
@router.post("/users/{user_id}/revoke", response_model=UserOut)
async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="계정을 찾을 수 없습니다.")
if user.is_admin and user.username == admin:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="자기 자신은 비활성화할 수 없습니다.")
user.is_active = False
await db.commit()
logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}")
return UserOut.model_validate(user, from_attributes=True)

View File

@@ -23,12 +23,9 @@ class Settings(BaseSettings):
DEBUG: bool = False # 운영 기본값 False (.env에서 DEBUG=true로 개발 모드 활성화)
SECRET_KEY: str = "change-this-secret-key" # JWT/WS 서명용
API_KEY: str = "" # API 인증용 (비어있으면 SECRET_KEY 폴백)
# ── 대시보드 로그인 (아이디/비밀번호) ────────────────────────────────
# ADMIN_PASSWORD_HASH(bcrypt) 우선, 없으면 ADMIN_PASSWORD(평문) 사용. 둘 다 비면 로그인 비활성(503)
# 해시 생성: python -c "import bcrypt; print(bcrypt.hashpw(b'비밀번호', bcrypt.gensalt()).decode())"
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = ""
ADMIN_PASSWORD_HASH: str = ""
# ── 대시보드 로그인 (DB users 테이블, bcrypt) ─────────────────────────
SIGNUP_ENABLED: bool = True # 회원가입 허용. False 면 /auth/register 403
SIGNUP_AUTO_APPROVE: bool = False # True: 가입 즉시 활성. False: 첫 가입자만 자동 관리자, 이후 관리자 승인 필요
SESSION_TOKEN_EXPIRE_HOURS: int = 24 # 로그인 세션 토큰 유효시간
LOGIN_MAX_FAILURES: int = 5 # IP당 연속 실패 허용 횟수
LOGIN_LOCKOUT_SECONDS: int = 300 # 초과 시 잠금 시간(초)

View File

@@ -8,6 +8,7 @@ SQLAlchemy ORM 모델 정의
- StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체)
- ExchangeRate: 환율 이력
- UsStockUniverse: 미국 주식 종목 유니버스
- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인)
"""
from datetime import datetime, date
from typing import Optional, List
@@ -384,3 +385,21 @@ class StrategyOptimizationLog(Base):
apply_reason = Column(Text, nullable=True)
total_combinations = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class User(Base):
"""대시보드 로그인 계정.
- 첫 가입자는 자동으로 관리자(is_admin) + 활성(is_active)
- 이후 가입자는 is_active=False 로 생성 → 관리자 승인 후 로그인 가능
(SIGNUP_AUTO_APPROVE=True 면 즉시 활성)
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(32), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False)
is_active = Column(Boolean, default=False, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_login_at = Column(DateTime, nullable=True)

View File

@@ -1,17 +1,18 @@
/**
* Login.tsx — 로그인 페이지 컴포넌트
* Login.tsx — 로그인 / 회원가입 페이지
*
* 역할:
* - 아이디/비밀번호 입력 → POST /api/v1/auth/login 으로 세션 토큰 발급
* - 발급된 토큰을 App 컴포넌트에 전달 (App 이 localStorage 'api_key' 에 저장,
* 이후 api.ts / useWebSocket 이 기존 X-API-Key 경로로 그대로 전송)
* - 인증 실패(401)·잠금(429)·미설정(503)·네트워크 오류 메시지 표시
* - 로그인: POST /api/v1/auth/login → 세션 토큰을 App 에 전달
* (App 이 localStorage 'api_key' 에 저장 → api.ts / useWebSocket 이 X-API-Key 로 전송)
* - 회원가입: POST /api/v1/auth/register → 첫 가입자는 관리자로 즉시 활성(자동 로그인),
* 이후 가입자는 관리자 승인 안내 후 로그인 탭으로 전환
* - 상태코드별 안내: 401 자격 오류 / 403 승인 대기·가입 비활성 / 409 아이디 중복 / 429 잠금
*
* Props:
* - onLogin: 인증 성공 시 세션 토큰을 전달받는 콜백
*/
import { useState, FormEvent } from 'react'
import { Activity, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react'
import { Activity, CheckCircle2, Eye, EyeOff, Loader2, Lock, User } from 'lucide-react'
/** Login 컴포넌트 Props 정의 */
interface LoginProps {
@@ -32,48 +33,105 @@ const errorBorder = 'border-red-500/60 focus:border-red-500 focus:ring-red-500/3
* - error: 인증 실패 에러 메시지
* - loading: 검증 요청 진행 중 여부 (버튼 비활성화)
*/
type Mode = 'login' | 'register'
/** 서버 detail 메시지 추출 (없으면 빈 문자열) */
const readDetail = async (res: Response): Promise<string> => {
try {
const data = (await res.json()) as { detail?: string | { msg?: string }[] }
if (typeof data.detail === 'string') return data.detail
if (Array.isArray(data.detail)) return data.detail[0]?.msg?.replace(/^Value error, /, '') ?? ''
} catch {
/* 본문 없음 */
}
return ''
}
const Login = ({ onLogin }: LoginProps) => {
const [mode, setMode] = useState<Mode>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [loading, setLoading] = useState(false)
/** 폼 제출 — 아이디/비밀번호로 세션 토큰 발급 */
const isRegister = mode === 'register'
const switchMode = (next: Mode) => {
setMode(next)
setError('')
setNotice('')
setPassword('')
setPasswordConfirm('')
}
/** 로그인 요청 — 성공 시 토큰 전달, 실패 시 에러 메시지 반환 */
const doLogin = async (u: string, p: string): Promise<string> => {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p }),
})
if (res.ok) {
const data = (await res.json()) as { token: string }
onLogin(data.token)
return ''
}
const detail = await readDetail(res)
if (res.status === 401) return detail || '아이디 또는 비밀번호가 올바르지 않습니다.'
if (res.status === 403) return detail || '관리자 승인 대기 중인 계정입니다.'
if (res.status === 429) return detail || '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.'
if (res.status === 422) return detail || '입력 형식이 올바르지 않습니다.'
return `서버 오류가 발생했습니다. (HTTP ${res.status})`
}
/** 회원가입 요청 — 즉시 활성 계정이면 자동 로그인 */
const doRegister = async (u: string, p: string): Promise<string> => {
const res = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p }),
})
if (res.ok) {
const data = (await res.json()) as { is_active: boolean; message: string }
if (data.is_active) return doLogin(u, p)
switchMode('login')
setNotice(data.message)
return ''
}
const detail = await readDetail(res)
if (res.status === 409) return detail || '이미 사용 중인 아이디입니다.'
if (res.status === 403) return detail || '회원가입이 비활성화되어 있습니다.'
if (res.status === 422) return detail || '아이디는 영문/숫자/밑줄 3~32자, 비밀번호는 8자 이상이어야 합니다.'
return `서버 오류가 발생했습니다. (HTTP ${res.status})`
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!username.trim() || !password) {
const u = username.trim()
if (!u || !password) {
setError('아이디와 비밀번호를 입력하세요.')
return
}
if (isRegister) {
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (password !== passwordConfirm) {
setError('비밀번호 확인이 일치하지 않습니다.')
return
}
}
setLoading(true)
setError('')
setNotice('')
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
})
if (res.ok) {
const data = (await res.json()) as { token: string }
onLogin(data.token)
return
}
let detail = ''
try {
detail = ((await res.json()) as { detail?: string }).detail ?? ''
} catch {
/* 본문 없음 */
}
if (res.status === 401) setError(detail || '아이디 또는 비밀번호가 올바르지 않습니다.')
else if (res.status === 429) setError(detail || '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.')
else if (res.status === 503) setError(detail || '서버에 로그인 계정이 설정되지 않았습니다.')
else setError(`서버 오류가 발생했습니다. (HTTP ${res.status})`)
const msg = isRegister ? await doRegister(u, password) : await doLogin(u, password)
if (msg) setError(msg)
} catch {
setError('서버 연결에 실패했습니다.')
} finally {
@@ -95,6 +153,36 @@ const Login = ({ onLogin }: LoginProps) => {
{/* 로그인 카드 */}
<div className="bg-[#161b22] border border-gray-800 rounded-2xl p-6 shadow-xl shadow-black/30">
{/* 로그인 / 회원가입 탭 */}
<div role="tablist" className="grid grid-cols-2 gap-1 p-1 mb-6 bg-[#0f1115] rounded-xl">
{(['login', 'register'] as Mode[]).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={mode === m}
onClick={() => switchMode(m)}
className={`py-2 text-sm font-medium rounded-lg transition-colors ${
mode === m
? 'bg-[#161b22] text-gray-100 shadow-sm'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{m === 'login' ? '로그인' : '회원가입'}
</button>
))}
</div>
{notice && (
<div
role="status"
className="flex items-start gap-2 mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-sm"
>
<CheckCircle2 size={16} className="mt-0.5 shrink-0" />
<span>{notice}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-2">
@@ -140,8 +228,8 @@ const Login = ({ onLogin }: LoginProps) => {
setPassword(e.target.value)
if (error) setError('')
}}
placeholder="비밀번호"
autoComplete="current-password"
placeholder={isRegister ? '8자 이상' : '비밀번호'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
aria-invalid={!!error}
aria-describedby={error ? 'login-error' : undefined}
className={`${inputClass} pr-11 ${error ? errorBorder : normalBorder}`}
@@ -156,12 +244,39 @@ const Login = ({ onLogin }: LoginProps) => {
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{isRegister && (
<div>
<label htmlFor="password-confirm" className="block text-sm font-medium text-gray-300 mb-2">
비밀번호 확인
</label>
<div className="relative">
<Lock
size={16}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none"
/>
<input
id="password-confirm"
type={showPw ? 'text' : 'password'}
value={passwordConfirm}
onChange={(e) => {
setPasswordConfirm(e.target.value)
if (error) setError('')
}}
placeholder="비밀번호 다시 입력"
autoComplete="new-password"
className={`${inputClass} pr-4 ${error ? errorBorder : normalBorder}`}
/>
</div>
</div>
)}
{error && (
<p id="login-error" role="alert" className="text-red-400 text-sm mt-2">
<p id="login-error" role="alert" className="text-red-400 text-sm">
{error}
</p>
)}
</div>
<button
type="submit"
@@ -169,13 +284,13 @@ const Login = ({ onLogin }: LoginProps) => {
className="w-full py-3 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
{loading && <Loader2 size={16} className="animate-spin" />}
{loading ? '확인 중' : '로그인'}
{loading ? '처리 중' : isRegister ? '가입하기' : '로그인'}
</button>
</form>
</div>
<p className="text-xs text-gray-600 text-center mt-6">
계정은 서버 <code className="font-mono text-gray-500">.env</code>의 ADMIN_USERNAME / ADMIN_PASSWORD 로 관리됩니다.
<p className="text-xs text-gray-600 text-center mt-6 leading-relaxed">
첫 번째 가입 계정이 관리자가 됩니다. 이후 가입자는 관리자 승인 후 로그인할 수 있습니다.
</p>
</div>
</div>