Fix: 업로드 및 API 연결 문제 해결
- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정 - Nginx 프록시 설정에서 경로 중복 문제 해결 - 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정) - 노트북 연결 기능 수정 (notebook_id 필드 추가) - 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거) - 헤더 UI 개선 및 고정 위치 설정 - 백업/복원 스크립트 추가 - PDF 미리보기 토큰 인증 지원
This commit is contained in:
BIN
DSCF0130.RAF_compressed.JPEG
Normal file
BIN
DSCF0130.RAF_compressed.JPEG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 690 KiB |
BIN
DSCF0313.RAF_compressed.JPEG
Normal file
BIN
DSCF0313.RAF_compressed.JPEG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
DSCF0390.RAF_compressed.JPEG
Normal file
BIN
DSCF0390.RAF_compressed.JPEG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
@@ -72,3 +72,4 @@ COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서
|
||||
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
|
||||
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
|
||||
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ python-dotenv = "^1.0.0"
|
||||
httpx = "^0.25.0"
|
||||
aiofiles = "^23.2.0"
|
||||
jinja2 = "^3.1.0"
|
||||
beautifulsoup4 = "^4.13.0"
|
||||
pypdf2 = "^3.0.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.4.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
API 의존성
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status, Query
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -86,3 +86,54 @@ async def get_optional_current_user(
|
||||
return await get_current_user(credentials, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user_with_token_param(
|
||||
_token: Optional[str] = Query(None),
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
|
||||
token = None
|
||||
|
||||
# URL 파라미터에서 토큰 확인
|
||||
if _token:
|
||||
token = _token
|
||||
# Authorization 헤더에서 토큰 확인
|
||||
elif credentials:
|
||||
token = credentials.credentials
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="No authentication token provided"
|
||||
)
|
||||
|
||||
try:
|
||||
# 토큰에서 사용자 ID 추출
|
||||
user_id = get_user_id_from_token(token)
|
||||
|
||||
# 데이터베이스에서 사용자 조회
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
print(f"🚫 토큰 인증 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
@@ -46,8 +46,11 @@ async def login(
|
||||
detail="Inactive user"
|
||||
)
|
||||
|
||||
# 토큰 생성
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
# 사용자별 세션 타임아웃을 적용한 토큰 생성
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
timeout_minutes=user.session_timeout_minutes
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_book(
|
||||
book_data: CreateBookRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -58,7 +58,7 @@ async def create_book(
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("/", response_model=List[BookResponse])
|
||||
@router.get("", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
문서 관리 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, and_, or_, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from ...core.config import settings
|
||||
from ...models.user import User
|
||||
from ...models.document import Document, Tag
|
||||
from ...models.book import Book
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
@@ -469,7 +469,7 @@ async def get_document(
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
_token: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
current_user: User = Depends(get_current_user_with_token_param),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 HTML 콘텐츠 조회"""
|
||||
@@ -512,7 +512,7 @@ async def get_document_content(
|
||||
async def get_document_pdf(
|
||||
document_id: str,
|
||||
_token: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
current_user: User = Depends(get_current_user_with_token_param),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 PDF 파일 조회"""
|
||||
@@ -535,6 +535,7 @@ async def get_document_pdf(
|
||||
|
||||
# PDF 파일 확인
|
||||
if not document.pdf_path:
|
||||
print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}")
|
||||
raise HTTPException(status_code=404, detail="PDF file not found for this document")
|
||||
|
||||
# PDF 파일 경로 처리
|
||||
@@ -544,9 +545,21 @@ async def get_document_pdf(
|
||||
if document.pdf_path.startswith('/'):
|
||||
file_path = document.pdf_path
|
||||
else:
|
||||
file_path = os.path.join("/app/data/documents", document.pdf_path)
|
||||
# PDF 파일은 /app/uploads에 저장됨
|
||||
file_path = os.path.join("/app", document.pdf_path)
|
||||
|
||||
print(f"🔍 PDF 파일 경로 확인: {file_path}")
|
||||
print(f"📁 데이터베이스 PDF 경로: {document.pdf_path}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"🚫 PDF 파일이 디스크에 없음: {file_path}")
|
||||
# 디렉토리 내용 확인
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if os.path.exists(dir_path):
|
||||
files = os.listdir(dir_path)
|
||||
print(f"📂 디렉토리 내용: {files[:10]}")
|
||||
else:
|
||||
print(f"📂 디렉토리도 없음: {dir_path}")
|
||||
raise HTTPException(status_code=404, detail="PDF file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
|
||||
@@ -178,6 +178,7 @@ def create_note_document(
|
||||
is_published=note_data.is_published,
|
||||
parent_note_id=note_data.parent_note_id,
|
||||
sort_order=note_data.sort_order,
|
||||
notebook_id=note_data.notebook_id,
|
||||
created_by=current_user.email,
|
||||
word_count=word_count,
|
||||
reading_time=reading_time
|
||||
|
||||
@@ -390,12 +390,15 @@ async def search_highlight_notes(
|
||||
# 하이라이트가 있는 노트만
|
||||
query_obj = query_obj.where(Note.highlight_id.isnot(None))
|
||||
|
||||
# Highlight와 조인 (권한 및 문서 필터링을 위해)
|
||||
query_obj = query_obj.join(Highlight)
|
||||
|
||||
# 권한 필터링 - 사용자의 노트만
|
||||
query_obj = query_obj.where(Note.created_by == current_user.id)
|
||||
query_obj = query_obj.where(Highlight.user_id == current_user.id)
|
||||
|
||||
# 특정 문서 필터
|
||||
if document_id:
|
||||
query_obj = query_obj.join(Highlight).where(Highlight.document_id == document_id)
|
||||
query_obj = query_obj.where(Highlight.document_id == document_id)
|
||||
|
||||
# 메모 내용에서 검색
|
||||
query_obj = query_obj.where(Note.content.ilike(f"%{query}%"))
|
||||
@@ -560,7 +563,7 @@ async def search_document_content(
|
||||
if doc.html_path.startswith('/'):
|
||||
html_file_path = doc.html_path
|
||||
else:
|
||||
html_file_path = os.path.join("/app/data/documents", doc.html_path)
|
||||
html_file_path = os.path.join("/app", doc.html_path)
|
||||
|
||||
if os.path.exists(html_file_path):
|
||||
with open(html_file_path, 'r', encoding='utf-8') as f:
|
||||
@@ -590,7 +593,7 @@ async def search_document_content(
|
||||
if doc.pdf_path.startswith('/'):
|
||||
pdf_file_path = doc.pdf_path
|
||||
else:
|
||||
pdf_file_path = os.path.join("/app/data/documents", doc.pdf_path)
|
||||
pdf_file_path = os.path.join("/app", doc.pdf_path)
|
||||
|
||||
if os.path.exists(pdf_file_path):
|
||||
with open(pdf_file_path, 'rb') as f:
|
||||
|
||||
104
backend/src/api/routes/setup.py
Normal file
104
backend/src/api/routes/setup.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
시스템 초기 설정 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import get_password_hash
|
||||
from ...models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InitialSetupRequest(BaseModel):
|
||||
"""초기 설정 요청"""
|
||||
admin_email: EmailStr
|
||||
admin_password: str
|
||||
admin_full_name: Optional[str] = None
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""설정 상태 응답"""
|
||||
is_setup_required: bool
|
||||
has_admin_user: bool
|
||||
total_users: int
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
async def get_setup_status(db: AsyncSession = Depends(get_db)):
|
||||
"""시스템 설정 상태 확인"""
|
||||
# 전체 사용자 수 조회
|
||||
total_users_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_users_result.scalar()
|
||||
|
||||
# 관리자 사용자 존재 여부 확인
|
||||
admin_result = await db.execute(
|
||||
select(User).where(User.role == "root")
|
||||
)
|
||||
has_admin_user = admin_result.scalar_one_or_none() is not None
|
||||
|
||||
return SetupStatusResponse(
|
||||
is_setup_required=total_users == 0 or not has_admin_user,
|
||||
has_admin_user=has_admin_user,
|
||||
total_users=total_users
|
||||
)
|
||||
|
||||
|
||||
@router.post("/initialize")
|
||||
async def initialize_system(
|
||||
setup_data: InitialSetupRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""시스템 초기 설정 (root 계정 생성)"""
|
||||
# 이미 설정된 시스템인지 확인
|
||||
existing_admin = await db.execute(
|
||||
select(User).where(User.role == "root")
|
||||
)
|
||||
if existing_admin.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="System is already initialized"
|
||||
)
|
||||
|
||||
# 이메일 중복 확인
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.email == setup_data.admin_email)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Root 관리자 계정 생성
|
||||
hashed_password = get_password_hash(setup_data.admin_password)
|
||||
|
||||
admin_user = User(
|
||||
email=setup_data.admin_email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=setup_data.admin_full_name or "시스템 관리자",
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role="root",
|
||||
can_manage_books=True,
|
||||
can_manage_notes=True,
|
||||
can_manage_novels=True
|
||||
)
|
||||
|
||||
db.add(admin_user)
|
||||
await db.commit()
|
||||
await db.refresh(admin_user)
|
||||
|
||||
return {
|
||||
"message": "System initialized successfully",
|
||||
"admin_user": {
|
||||
"id": str(admin_user.id),
|
||||
"email": admin_user.email,
|
||||
"full_name": admin_user.full_name,
|
||||
"role": admin_user.role
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,276 @@
|
||||
"""
|
||||
사용자 관리 API 라우터
|
||||
사용자 관리 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, delete
|
||||
from typing import List
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import get_password_hash, verify_password
|
||||
from ...models.user import User
|
||||
from ...schemas.auth import UserInfo
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""프로필 업데이트 요청"""
|
||||
full_name: str = None
|
||||
theme: str = None
|
||||
language: str = None
|
||||
timezone: str = None
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""사용자 정보 업데이트 요청 (관리자용)"""
|
||||
full_name: str = None
|
||||
is_active: bool = None
|
||||
is_admin: bool = None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserInfo)
|
||||
async def get_profile(
|
||||
class UserResponse(BaseModel):
|
||||
"""사용자 응답"""
|
||||
id: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: str
|
||||
can_manage_books: bool
|
||||
can_manage_notes: bool
|
||||
can_manage_novels: bool
|
||||
session_timeout_minutes: int
|
||||
theme: str
|
||||
language: str
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
"""사용자 생성 요청"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
role: str = "user"
|
||||
can_manage_books: bool = True
|
||||
can_manage_notes: bool = True
|
||||
can_manage_novels: bool = True
|
||||
session_timeout_minutes: int = 5
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
"""사용자 업데이트 요청"""
|
||||
full_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
can_manage_books: Optional[bool] = None
|
||||
can_manage_notes: Optional[bool] = None
|
||||
can_manage_novels: Optional[bool] = None
|
||||
session_timeout_minutes: Optional[int] = None
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""프로필 업데이트 요청"""
|
||||
full_name: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""비밀번호 변경 요청"""
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_profile(
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""현재 사용자 프로필 조회"""
|
||||
return UserInfo.from_orm(current_user)
|
||||
return UserResponse(
|
||||
id=str(current_user.id),
|
||||
email=current_user.email,
|
||||
full_name=current_user.full_name,
|
||||
is_active=current_user.is_active,
|
||||
is_admin=current_user.is_admin,
|
||||
role=current_user.role,
|
||||
can_manage_books=current_user.can_manage_books,
|
||||
can_manage_notes=current_user.can_manage_notes,
|
||||
can_manage_novels=current_user.can_manage_novels,
|
||||
session_timeout_minutes=current_user.session_timeout_minutes,
|
||||
theme=current_user.theme,
|
||||
language=current_user.language,
|
||||
timezone=current_user.timezone,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at,
|
||||
last_login=current_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.put("/profile", response_model=UserInfo)
|
||||
async def update_profile(
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user_profile(
|
||||
profile_data: UpdateProfileRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""프로필 업데이트"""
|
||||
update_data = {}
|
||||
"""현재 사용자 프로필 업데이트"""
|
||||
update_fields = profile_data.model_dump(exclude_unset=True)
|
||||
|
||||
if profile_data.full_name is not None:
|
||||
update_data["full_name"] = profile_data.full_name
|
||||
if profile_data.theme is not None:
|
||||
update_data["theme"] = profile_data.theme
|
||||
if profile_data.language is not None:
|
||||
update_data["language"] = profile_data.language
|
||||
if profile_data.timezone is not None:
|
||||
update_data["timezone"] = profile_data.timezone
|
||||
for field, value in update_fields.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == current_user.id)
|
||||
.values(**update_data)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(current_user.id),
|
||||
email=current_user.email,
|
||||
full_name=current_user.full_name,
|
||||
is_active=current_user.is_active,
|
||||
is_admin=current_user.is_admin,
|
||||
role=current_user.role,
|
||||
can_manage_books=current_user.can_manage_books,
|
||||
can_manage_notes=current_user.can_manage_notes,
|
||||
can_manage_novels=current_user.can_manage_novels,
|
||||
session_timeout_minutes=current_user.session_timeout_minutes,
|
||||
theme=current_user.theme,
|
||||
language=current_user.language,
|
||||
timezone=current_user.timezone,
|
||||
created_at=current_user.created_at,
|
||||
updated_at=current_user.updated_at,
|
||||
last_login=current_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/change-password")
|
||||
async def change_current_user_password(
|
||||
password_data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""현재 사용자 비밀번호 변경"""
|
||||
# 현재 비밀번호 확인
|
||||
if not verify_password(password_data.current_password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect"
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserInfo.from_orm(current_user)
|
||||
# 새 비밀번호 설정
|
||||
current_user.hashed_password = get_password_hash(password_data.new_password)
|
||||
current_user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserInfo])
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 목록 조회 (관리자 전용)"""
|
||||
result = await db.execute(select(User).order_by(User.created_at.desc()))
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
return [UserInfo.from_orm(user) for user in users]
|
||||
|
||||
return [
|
||||
UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserInfo)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
@router.post("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
user_data: CreateUserRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""특정 사용자 조회 (관리자 전용)"""
|
||||
"""사용자 생성 (관리자 전용)"""
|
||||
# 이메일 중복 확인
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.email == user_data.email)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# 권한 확인 (root만 admin/root 계정 생성 가능)
|
||||
if user_data.role in ["admin", "root"] and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can create admin accounts"
|
||||
)
|
||||
|
||||
# 사용자 생성
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=user_data.full_name,
|
||||
is_active=True,
|
||||
is_admin=user_data.role in ["admin", "root"],
|
||||
role=user_data.role,
|
||||
can_manage_books=user_data.can_manage_books,
|
||||
can_manage_notes=user_data.can_manage_notes,
|
||||
can_manage_novels=user_data.can_manage_novels,
|
||||
session_timeout_minutes=user_data.session_timeout_minutes
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(new_user.id),
|
||||
email=new_user.email,
|
||||
full_name=new_user.full_name,
|
||||
is_active=new_user.is_active,
|
||||
is_admin=new_user.is_admin,
|
||||
role=new_user.role,
|
||||
can_manage_books=new_user.can_manage_books,
|
||||
can_manage_notes=new_user.can_manage_notes,
|
||||
can_manage_novels=new_user.can_manage_novels,
|
||||
session_timeout_minutes=new_user.session_timeout_minutes,
|
||||
theme=new_user.theme,
|
||||
language=new_user.language,
|
||||
timezone=new_user.timezone,
|
||||
created_at=new_user.created_at,
|
||||
updated_at=new_user.updated_at,
|
||||
last_login=new_user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 상세 조회 (관리자 전용)"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -96,18 +280,34 @@ async def get_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserInfo.from_orm(user)
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserInfo)
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_data: UpdateUserRequest,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 정보 업데이트 (관리자 전용)"""
|
||||
# 사용자 존재 확인
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -117,42 +317,55 @@ async def update_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신의 관리자 권한은 제거할 수 없음
|
||||
if user.id == admin_user.id and user_data.is_admin is False:
|
||||
# 권한 확인 (root만 admin/root 계정 수정 가능)
|
||||
if user.role in ["admin", "root"] and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove admin privileges from yourself"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can modify admin accounts"
|
||||
)
|
||||
|
||||
# 업데이트할 데이터 준비
|
||||
update_data = {}
|
||||
if user_data.full_name is not None:
|
||||
update_data["full_name"] = user_data.full_name
|
||||
if user_data.is_active is not None:
|
||||
update_data["is_active"] = user_data.is_active
|
||||
if user_data.is_admin is not None:
|
||||
update_data["is_admin"] = user_data.is_admin
|
||||
# 업데이트할 필드들 적용
|
||||
update_fields = user_data.model_dump(exclude_unset=True)
|
||||
|
||||
if update_data:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
for field, value in update_fields.items():
|
||||
if field == "role":
|
||||
# 역할 변경 시 is_admin도 함께 업데이트
|
||||
setattr(user, field, value)
|
||||
user.is_admin = value in ["admin", "root"]
|
||||
else:
|
||||
setattr(user, field, value)
|
||||
|
||||
return UserInfo.from_orm(user)
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
role=user.role,
|
||||
can_manage_books=user.can_manage_books,
|
||||
can_manage_notes=user.can_manage_notes,
|
||||
can_manage_novels=user.can_manage_novels,
|
||||
session_timeout_minutes=user.session_timeout_minutes,
|
||||
theme=user.theme,
|
||||
language=user.language,
|
||||
timezone=user.timezone,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
admin_user: User = Depends(get_current_admin_user),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""사용자 삭제 (관리자 전용)"""
|
||||
# 사용자 존재 확인
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -162,15 +375,28 @@ async def delete_user(
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 자기 자신은 삭제할 수 없음
|
||||
if user.id == admin_user.id:
|
||||
# 자기 자신 삭제 방지
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete yourself"
|
||||
detail="Cannot delete your own account"
|
||||
)
|
||||
|
||||
# root 계정 삭제 방지
|
||||
if user.role == "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete root account"
|
||||
)
|
||||
|
||||
# 권한 확인 (root만 admin 계정 삭제 가능)
|
||||
if user.role == "admin" and current_user.role != "root":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only root users can delete admin accounts"
|
||||
)
|
||||
|
||||
# 사용자 삭제
|
||||
await db.execute(delete(User).where(User.id == user_id))
|
||||
await db.commit()
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
return {"message": "User deleted successfully"}
|
||||
@@ -24,11 +24,18 @@ def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str:
|
||||
"""액세스 토큰 생성"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
elif timeout_minutes is not None:
|
||||
if timeout_minutes == 0:
|
||||
# 무제한 토큰 (1년으로 설정)
|
||||
expire = datetime.utcnow() + timedelta(days=365)
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes
|
||||
from .api.routes.notes import router as note_documents_router
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
|
||||
from .api.routes import note_documents, note_links
|
||||
|
||||
|
||||
@@ -44,19 +43,19 @@ app.add_middleware(
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
# API 라우터 등록
|
||||
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["사용자"])
|
||||
app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
|
||||
app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"])
|
||||
app.include_router(notes.router, prefix="/api/notes", tags=["메모"])
|
||||
app.include_router(notes.router, prefix="/api/highlight-notes", tags=["하이라이트 메모"])
|
||||
app.include_router(books.router, prefix="/api/books", tags=["서적"])
|
||||
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
|
||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
|
||||
app.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"])
|
||||
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서 관리"])
|
||||
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
|
||||
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
|
||||
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
|
||||
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
|
||||
|
||||
@@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel):
|
||||
tags: List[str] = Field(default=[])
|
||||
is_published: bool = Field(default=False)
|
||||
parent_note_id: Optional[str] = None
|
||||
notebook_id: Optional[str] = None
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
class NoteDocumentCreate(NoteDocumentBase):
|
||||
@@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel):
|
||||
tags: Optional[List[str]] = None
|
||||
is_published: Optional[bool] = None
|
||||
parent_note_id: Optional[str] = None
|
||||
notebook_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
class NoteDocumentResponse(NoteDocumentBase):
|
||||
@@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase):
|
||||
'tags': obj.tags or [],
|
||||
'is_published': obj.is_published,
|
||||
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
|
||||
'notebook_id': str(obj.notebook_id) if obj.notebook_id else None,
|
||||
'sort_order': obj.sort_order,
|
||||
'markdown_content': obj.markdown_content,
|
||||
'created_at': obj.created_at,
|
||||
|
||||
@@ -55,3 +55,4 @@ class NoteLink(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
사용자 모델
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
@@ -21,6 +21,17 @@ class User(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
|
||||
# 권한 시스템 (서적관리, 노트관리, 소설관리)
|
||||
can_manage_books = Column(Boolean, default=True) # 서적 관리 권한
|
||||
can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한
|
||||
can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한
|
||||
|
||||
# 사용자 역할 (root, admin, user)
|
||||
role = Column(String(20), default="user") # root, admin, user
|
||||
|
||||
# 세션 타임아웃 설정 (분 단위, 0 = 무제한)
|
||||
session_timeout_minutes = Column(Integer, default=5) # 기본 5분
|
||||
|
||||
# 메타데이터
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
@@ -33,10 +33,16 @@ class UserInfo(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: str
|
||||
can_manage_books: bool
|
||||
can_manage_notes: bool
|
||||
can_manage_novels: bool
|
||||
session_timeout_minutes: int
|
||||
theme: str
|
||||
language: str
|
||||
timezone: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -89,3 +89,4 @@ jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값
|
||||
|
||||
# 확장 모듈 설정
|
||||
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈
|
||||
|
||||
|
||||
@@ -152,3 +152,4 @@ volumes:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume2/document-storage
|
||||
|
||||
|
||||
363
frontend/account-settings.html
Normal file
363
frontend/account-settings.html
Normal file
@@ -0,0 +1,363 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>계정 설정 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 메인 앱 -->
|
||||
<div x-data="accountSettingsApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-cog text-2xl text-blue-600"></i>
|
||||
<h1 class="text-3xl font-bold text-gray-900">계정 설정</h1>
|
||||
</div>
|
||||
<p class="text-gray-600">계정 정보와 환경 설정을 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-600' : 'fas fa-exclamation-circle text-red-600'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설정 섹션들 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 프로필 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-user text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">프로필 정보</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateProfile()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input type="email" x-model="profile.email" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
|
||||
<p class="mt-1 text-xs text-gray-500">이메일은 변경할 수 없습니다</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">이름</label>
|
||||
<input type="text" x-model="profile.full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="이름을 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<i class="fas fa-save"></i>
|
||||
<span x-text="loading ? '저장 중...' : '프로필 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 비밀번호 변경 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-lock text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">비밀번호 변경</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="changePassword()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
|
||||
<input type="password" x-model="passwordForm.currentPassword" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="현재 비밀번호를 입력하세요">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
|
||||
<input type="password" x-model="passwordForm.newPassword" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="새 비밀번호를 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
|
||||
<input type="password" x-model="passwordForm.confirmPassword" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="새 비밀번호를 다시 입력하세요">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<i class="fas fa-key"></i>
|
||||
<span x-text="loading ? '변경 중...' : '비밀번호 변경'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 환경 설정 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-palette text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">환경 설정</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateSettings()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">테마</label>
|
||||
<select x-model="settings.theme"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="light">라이트</option>
|
||||
<option value="dark">다크</option>
|
||||
<option value="auto">자동</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">언어</label>
|
||||
<select x-model="settings.language"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
|
||||
<select x-model="settings.timezone"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="Asia/Seoul">서울 (UTC+9)</option>
|
||||
<option value="UTC">UTC (UTC+0)</option>
|
||||
<option value="America/New_York">뉴욕 (UTC-5)</option>
|
||||
<option value="Europe/London">런던 (UTC+0)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span x-text="loading ? '저장 중...' : '설정 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 계정 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-info-circle text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">계정 정보</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">계정 ID:</span>
|
||||
<span class="text-gray-600 ml-2" x-text="profile.id"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">역할:</span>
|
||||
<span class="text-gray-600 ml-2" x-text="getRoleText(profile.role)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">계정 생성일:</span>
|
||||
<span class="text-gray-600 ml-2" x-text="formatDate(profile.created_at)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">마지막 로그인:</span>
|
||||
<span class="text-gray-600 ml-2" x-text="formatDate(profile.last_login)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">세션 타임아웃:</span>
|
||||
<span class="text-gray-600 ml-2" x-text="profile.session_timeout_minutes === 0 ? '무제한' : profile.session_timeout_minutes + '분'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">계정 상태:</span>
|
||||
<span class="ml-2" :class="profile.is_active ? 'text-green-600' : 'text-red-600'" x-text="profile.is_active ? '활성' : '비활성'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
|
||||
<!-- 계정 설정 스크립트 -->
|
||||
<script>
|
||||
function accountSettingsApp() {
|
||||
return {
|
||||
loading: false,
|
||||
profile: {
|
||||
id: '',
|
||||
email: '',
|
||||
full_name: '',
|
||||
role: '',
|
||||
is_active: false,
|
||||
created_at: '',
|
||||
last_login: '',
|
||||
session_timeout_minutes: 5
|
||||
},
|
||||
passwordForm: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
},
|
||||
settings: {
|
||||
theme: 'light',
|
||||
language: 'ko',
|
||||
timezone: 'Asia/Seoul'
|
||||
},
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success'
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 계정 설정 앱 초기화');
|
||||
await this.loadProfile();
|
||||
},
|
||||
|
||||
async loadProfile() {
|
||||
try {
|
||||
const user = await api.getCurrentUser();
|
||||
this.profile = { ...user };
|
||||
this.settings = {
|
||||
theme: user.theme || 'light',
|
||||
language: user.language || 'ko',
|
||||
timezone: user.timezone || 'Asia/Seoul'
|
||||
};
|
||||
console.log('✅ 프로필 로드 완료:', user);
|
||||
} catch (error) {
|
||||
console.error('❌ 프로필 로드 실패:', error);
|
||||
this.showNotification('프로필을 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async updateProfile() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await api.put(`/users/${this.profile.id}`, {
|
||||
full_name: this.profile.full_name
|
||||
});
|
||||
|
||||
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
|
||||
await this.loadProfile(); // 프로필 다시 로드
|
||||
} catch (error) {
|
||||
console.error('❌ 프로필 업데이트 실패:', error);
|
||||
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.newPassword !== this.passwordForm.confirmPassword) {
|
||||
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
await api.post('/auth/change-password', {
|
||||
current_password: this.passwordForm.currentPassword,
|
||||
new_password: this.passwordForm.newPassword
|
||||
});
|
||||
|
||||
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
|
||||
this.passwordForm = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ 비밀번호 변경 실패:', error);
|
||||
this.showNotification('비밀번호 변경에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async updateSettings() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await api.put(`/users/${this.profile.id}`, {
|
||||
theme: this.settings.theme,
|
||||
language: this.settings.language,
|
||||
timezone: this.settings.timezone
|
||||
});
|
||||
|
||||
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
|
||||
await this.loadProfile(); // 프로필 다시 로드
|
||||
} catch (error) {
|
||||
console.error('❌ 환경 설정 저장 실패:', error);
|
||||
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString('ko-KR');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 공통 헤더 컴포넌트 -->
|
||||
<header class="header-modern fade-in">
|
||||
<header class="header-modern fade-in fixed top-0 left-0 right-0 z-50">
|
||||
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<!-- 로고 -->
|
||||
@@ -8,107 +8,230 @@
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 - 3가지 기능 -->
|
||||
<nav class="flex space-x-6">
|
||||
<!-- 문서 관리 시스템 -->
|
||||
<!-- 메인 네비게이션 -->
|
||||
<nav class="hidden md:flex items-center space-x-1 relative">
|
||||
<!-- 문서 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="index.html" class="nav-link" id="doc-nav-link">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<button class="nav-link-modern" id="doc-nav-link">
|
||||
<i class="fas fa-folder-open text-blue-600"></i>
|
||||
<span>문서 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
|
||||
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
|
||||
</a>
|
||||
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
|
||||
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
|
||||
</a>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-th-large text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">문서 관리</div>
|
||||
<div class="text-xs text-gray-500">HTML 문서 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="pdf-manager.html" class="nav-dropdown-card" id="pdf-manager-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-file-pdf text-red-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">PDF 관리</div>
|
||||
<div class="text-xs text-gray-500">PDF 파일 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통합 검색 -->
|
||||
<a href="search.html" class="nav-link" id="search-nav-link">
|
||||
<i class="fas fa-search"></i>
|
||||
<a href="search.html" class="nav-link-modern" id="search-nav-link">
|
||||
<i class="fas fa-search text-green-600"></i>
|
||||
<span>통합 검색</span>
|
||||
</a>
|
||||
|
||||
<!-- 소설 관리 시스템 -->
|
||||
<!-- 소설 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
|
||||
<i class="fas fa-feather-alt"></i>
|
||||
<button class="nav-link-modern" id="novel-nav-link">
|
||||
<i class="fas fa-feather-alt text-purple-600"></i>
|
||||
<span>소설 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
|
||||
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
|
||||
</a>
|
||||
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
|
||||
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
|
||||
</a>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-sitemap text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">트리 뷰</div>
|
||||
<div class="text-xs text-gray-500">계층형 메모 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="story-view.html" class="nav-dropdown-card" id="story-view-nav-item">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-book-open text-orange-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">스토리 뷰</div>
|
||||
<div class="text-xs text-gray-500">스토리 읽기 모드</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 관리 시스템 -->
|
||||
<!-- 노트 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="notes.html" class="nav-link" id="notes-nav-link">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<button class="nav-link-modern" id="notes-nav-link">
|
||||
<i class="fas fa-sticky-note text-yellow-600"></i>
|
||||
<span>노트 관리</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
|
||||
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
|
||||
</a>
|
||||
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
|
||||
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
|
||||
</a>
|
||||
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
|
||||
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
|
||||
</a>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-book text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
|
||||
<div class="text-xs text-gray-500">그룹 관리</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="notes.html" class="nav-dropdown-card" id="notes-list-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-list text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">노트 목록</div>
|
||||
<div class="text-xs text-gray-500">전체 보기</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="note-editor.html" class="nav-dropdown-card" id="note-editor-nav-item">
|
||||
<div class="flex flex-col items-center text-center space-y-2">
|
||||
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-edit text-purple-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 text-sm">새 노트 작성</div>
|
||||
<div class="text-xs text-gray-500">노트 만들기</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 모바일 메뉴 버튼 -->
|
||||
<div class="md:hidden">
|
||||
<button x-data="{ open: false }" @click="open = !open" class="mobile-menu-btn">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 메뉴 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- PDF 관리 버튼 -->
|
||||
<a href="pdf-manager.html" class="nav-link" title="PDF 관리">
|
||||
<i class="fas fa-file-pdf text-red-500"></i>
|
||||
<span class="hidden sm:inline">PDF</span>
|
||||
</a>
|
||||
|
||||
|
||||
<!-- 언어 전환 버튼 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link" title="언어 설정">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="hidden sm:inline">한국어</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<button class="nav-dropdown-item" onclick="handleLanguageChange('ko')">
|
||||
<i class="fas fa-flag mr-2 text-blue-500"></i>한국어
|
||||
</button>
|
||||
<button class="nav-dropdown-item" onclick="handleLanguageChange('en')">
|
||||
<i class="fas fa-flag mr-2 text-red-500"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인/로그아웃 -->
|
||||
<!-- 사용자 계정 메뉴 -->
|
||||
<div class="flex items-center space-x-3" id="user-menu">
|
||||
<!-- 로그인된 사용자 -->
|
||||
<div class="hidden" id="logged-in-menu">
|
||||
<span class="text-sm text-gray-600" id="user-name">User</span>
|
||||
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
|
||||
<i class="fas fa-sign-out-alt"></i> 로그아웃
|
||||
<!-- 로그인된 사용자 드롭다운 -->
|
||||
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
|
||||
<button @click="open = !open" class="user-menu-btn">
|
||||
<div class="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
|
||||
<i class="fas fa-user text-white text-sm"></i>
|
||||
</div>
|
||||
<div class="hidden sm:block text-left">
|
||||
<div class="text-sm font-semibold text-gray-900" id="user-name">User</div>
|
||||
<div class="text-xs text-gray-500" id="user-role">사용자</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
|
||||
<!-- 드롭다운 메뉴 -->
|
||||
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="user-dropdown">
|
||||
<!-- 사용자 정보 -->
|
||||
<div class="px-4 py-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-md">
|
||||
<i class="fas fa-user text-white"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900" id="dropdown-user-name">User</div>
|
||||
<div class="text-sm text-gray-600" id="dropdown-user-email">user@example.com</div>
|
||||
<div class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 mt-1" id="dropdown-user-role">사용자</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 항목들 -->
|
||||
<div class="py-2">
|
||||
<a href="profile.html" class="user-menu-item">
|
||||
<i class="fas fa-user-edit text-blue-500"></i>
|
||||
<div>
|
||||
<div class="font-medium">프로필 관리</div>
|
||||
<div class="text-xs text-gray-500">개인 정보 수정</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="account-settings.html" class="user-menu-item">
|
||||
<i class="fas fa-cog text-gray-500"></i>
|
||||
<div>
|
||||
<div class="font-medium">계정 설정</div>
|
||||
<div class="text-xs text-gray-500">환경 설정 및 보안</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 관리자 메뉴 (관리자만 표시) -->
|
||||
<div class="hidden" id="admin-menu-section">
|
||||
<div class="border-t border-gray-100 my-2"></div>
|
||||
<div class="px-4 py-2">
|
||||
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider">관리자 메뉴</div>
|
||||
</div>
|
||||
<a href="user-management.html" class="user-menu-item">
|
||||
<i class="fas fa-users text-indigo-500"></i>
|
||||
<div>
|
||||
<div class="font-medium">사용자 관리</div>
|
||||
<div class="text-xs text-gray-500">계정 및 권한 관리</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="system-settings.html" class="user-menu-item">
|
||||
<i class="fas fa-server text-green-500"></i>
|
||||
<div>
|
||||
<div class="font-medium">시스템 설정</div>
|
||||
<div class="text-xs text-gray-500">시스템 전체 설정</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 로그아웃 -->
|
||||
<div class="border-t border-gray-100 my-2"></div>
|
||||
<button onclick="handleLogout()" class="user-menu-item text-red-600 hover:bg-red-50 w-full">
|
||||
<i class="fas fa-sign-out-alt text-red-500"></i>
|
||||
<div>
|
||||
<div class="font-medium">로그아웃</div>
|
||||
<div class="text-xs text-gray-500">계정에서 로그아웃</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 버튼 -->
|
||||
<div class="hidden" id="login-button">
|
||||
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
|
||||
<i class="fas fa-sign-in-alt"></i> 로그인
|
||||
<div class="" id="login-button">
|
||||
<button id="login-btn" class="login-btn-modern">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>로그인</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,35 +242,128 @@
|
||||
|
||||
<!-- 헤더 관련 스타일 -->
|
||||
<style>
|
||||
/* 네비게이션 링크 스타일 */
|
||||
.nav-link {
|
||||
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
|
||||
/* 모던 네비게이션 링크 스타일 */
|
||||
.nav-link-modern {
|
||||
@apply flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
.nav-link-modern:hover {
|
||||
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@apply bg-gray-50;
|
||||
.nav-link-modern.active {
|
||||
@apply text-blue-700 bg-blue-50 border-blue-200;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* 드롭다운 메뉴 스타일 */
|
||||
.nav-dropdown {
|
||||
@apply absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-2 min-w-44 z-50;
|
||||
/* 와이드 드롭다운 메뉴 스타일 */
|
||||
.nav-dropdown-wide {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
margin-top: 0.5rem !important;
|
||||
z-index: 9999 !important;
|
||||
min-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
padding: 1rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
|
||||
.nav-dropdown-card {
|
||||
@apply block p-4 bg-white border border-gray-100 rounded-lg hover:border-gray-200 hover:shadow-md transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.nav-dropdown-item.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
.nav-dropdown-card:hover {
|
||||
@apply bg-gradient-to-br from-gray-50 to-blue-50 transform -translate-y-1;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-dropdown-card.active {
|
||||
@apply border-blue-200 bg-blue-50;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* 드롭다운 컨테이너 안정성 */
|
||||
nav > div.relative {
|
||||
position: relative !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
/* 애니메이션 중 위치 고정 */
|
||||
.nav-dropdown-wide[x-show] {
|
||||
position: absolute !important;
|
||||
top: 100% !important;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
|
||||
/* 모바일 메뉴 버튼 */
|
||||
.mobile-menu-btn {
|
||||
@apply p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* 사용자 메뉴 스타일 */
|
||||
.user-menu-btn {
|
||||
@apply flex items-center space-x-3 px-3 py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.user-menu-btn:hover {
|
||||
@apply shadow-sm;
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
@apply absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl py-0 z-50;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
@apply flex items-center space-x-3 px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-all duration-150 cursor-pointer;
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
@apply bg-gradient-to-r from-gray-50 to-blue-50 text-gray-900;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.user-menu-item i {
|
||||
@apply w-5 h-5 flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* 로그인 버튼 모던 스타일 */
|
||||
.login-btn-modern {
|
||||
@apply flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
|
||||
}
|
||||
|
||||
.login-btn-modern:hover {
|
||||
@apply from-blue-700 to-blue-800 transform -translate-y-0.5;
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.login-btn-modern:active {
|
||||
@apply transform translate-y-0;
|
||||
}
|
||||
|
||||
/* 헤더 모던 스타일 */
|
||||
.header-modern {
|
||||
@apply bg-white border-b border-gray-200 shadow-sm;
|
||||
@apply bg-white/95 backdrop-blur-md border-b border-gray-200/50 shadow-lg;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 헤더 호버 효과 */
|
||||
.header-modern:hover {
|
||||
@apply bg-white shadow-xl;
|
||||
}
|
||||
|
||||
/* 언어 전환 스타일 */
|
||||
@@ -218,34 +434,9 @@
|
||||
|
||||
// 로그인 관련 함수들
|
||||
window.handleLogin = () => {
|
||||
console.log('🔐 handleLogin 호출됨');
|
||||
|
||||
// Alpine.js 컨텍스트에서 함수 찾기
|
||||
const bodyElement = document.querySelector('body');
|
||||
if (bodyElement && bodyElement._x_dataStack) {
|
||||
const alpineData = bodyElement._x_dataStack[0];
|
||||
if (alpineData && typeof alpineData.openLoginModal === 'function') {
|
||||
console.log('✅ Alpine 컨텍스트에서 openLoginModal 호출');
|
||||
alpineData.openLoginModal();
|
||||
return;
|
||||
}
|
||||
if (alpineData && alpineData.showLoginModal !== undefined) {
|
||||
console.log('✅ Alpine 컨텍스트에서 showLoginModal 설정');
|
||||
alpineData.showLoginModal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 시도
|
||||
if (typeof window.openLoginModal === 'function') {
|
||||
console.log('✅ 전역 openLoginModal 호출');
|
||||
window.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 직접 이벤트 발생
|
||||
console.log('🔄 커스텀 이벤트로 로그인 모달 열기');
|
||||
document.dispatchEvent(new CustomEvent('open-login-modal'));
|
||||
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
};
|
||||
|
||||
window.handleLogout = () => {
|
||||
@@ -259,22 +450,73 @@
|
||||
|
||||
// 사용자 상태 업데이트 함수
|
||||
window.updateUserMenu = (user) => {
|
||||
console.log('🔄 updateUserMenu 호출됨:', user);
|
||||
|
||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const adminMenuSection = document.getElementById('admin-menu-section');
|
||||
|
||||
console.log('🔍 요소 찾기:', {
|
||||
loggedInMenu: !!loggedInMenu,
|
||||
loginButton: !!loginButton,
|
||||
adminMenuSection: !!adminMenuSection
|
||||
});
|
||||
|
||||
// 사용자 정보 요소들
|
||||
const userName = document.getElementById('user-name');
|
||||
const userRole = document.getElementById('user-role');
|
||||
const dropdownUserName = document.getElementById('dropdown-user-name');
|
||||
const dropdownUserEmail = document.getElementById('dropdown-user-email');
|
||||
const dropdownUserRole = document.getElementById('dropdown-user-role');
|
||||
|
||||
if (user) {
|
||||
// 로그인된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.remove('hidden');
|
||||
if (loginButton) loginButton.classList.add('hidden');
|
||||
if (userName) userName.textContent = user.username || user.full_name || user.email || 'User';
|
||||
console.log('✅ 사용자 로그인 상태 - UI 업데이트 시작');
|
||||
if (loggedInMenu) {
|
||||
loggedInMenu.classList.remove('hidden');
|
||||
console.log('✅ 로그인 메뉴 표시');
|
||||
}
|
||||
if (loginButton) {
|
||||
loginButton.classList.add('hidden');
|
||||
console.log('✅ 로그인 버튼 숨김');
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
const displayName = user.full_name || user.email || 'User';
|
||||
const roleText = getRoleText(user.role);
|
||||
|
||||
if (userName) userName.textContent = displayName;
|
||||
if (userRole) userRole.textContent = roleText;
|
||||
if (dropdownUserName) dropdownUserName.textContent = displayName;
|
||||
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
|
||||
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
|
||||
|
||||
// 관리자 메뉴 표시/숨김
|
||||
if (adminMenuSection) {
|
||||
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
|
||||
adminMenuSection.classList.remove('hidden');
|
||||
} else {
|
||||
adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
if (adminMenuSection) adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 역할 텍스트 변환 함수
|
||||
function getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
}
|
||||
|
||||
// 언어 토글 함수 (전역)
|
||||
// 통합 언어 변경 함수
|
||||
window.handleLanguageChange = (lang) => {
|
||||
@@ -335,9 +577,27 @@
|
||||
// 향후 다국어 지원 시 구현
|
||||
};
|
||||
|
||||
// 헤더 로드 완료 후 언어 설정 적용
|
||||
// 헤더 로드 완료 후 이벤트 바인딩
|
||||
document.addEventListener('headerLoaded', () => {
|
||||
console.log('🔧 헤더 로드 완료 - 언어 전환 함수 등록');
|
||||
console.log('🔧 헤더 로드 완료 - 이벤트 바인딩 시작');
|
||||
|
||||
// 로그인 버튼 이벤트 리스너 추가
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', () => {
|
||||
console.log('🔐 로그인 버튼 클릭됨');
|
||||
if (typeof window.handleLogin === 'function') {
|
||||
window.handleLogin();
|
||||
} else {
|
||||
console.error('❌ handleLogin 함수를 찾을 수 없습니다');
|
||||
}
|
||||
});
|
||||
console.log('✅ 로그인 버튼 이벤트 리스너 등록 완료');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 언어 설정 적용
|
||||
const savedLang = localStorage.getItem('preferred_language') || 'ko';
|
||||
console.log('💾 저장된 언어 설정 적용:', savedLang);
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
</head>
|
||||
|
||||
316
frontend/login.html
Normal file
316
frontend/login.html
Normal file
@@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>로그인 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
|
||||
<style>
|
||||
/* 배경 이미지 스타일 */
|
||||
.login-background {
|
||||
background: url('static/images/login-bg.jpg') center/cover;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 기본 배경 (이미지가 없을 때) */
|
||||
.login-background-fallback {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(147, 51, 234, 0.3));
|
||||
}
|
||||
|
||||
/* 글래스모피즘 효과 */
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 입력 필드 포커스 효과 */
|
||||
.input-glow:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 로그인 버튼 호버 효과 */
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* 파티클 애니메이션 */
|
||||
.particle {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
|
||||
/* 로고 영역 개선 */
|
||||
.logo-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen login-background-fallback" x-data="loginApp()">
|
||||
<!-- 배경 파티클 -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="particle w-2 h-2" style="left: 10%; top: 20%; animation-delay: 0s;"></div>
|
||||
<div class="particle w-3 h-3" style="left: 20%; top: 80%; animation-delay: 2s;"></div>
|
||||
<div class="particle w-1 h-1" style="left: 80%; top: 30%; animation-delay: 4s;"></div>
|
||||
<div class="particle w-2 h-2" style="left: 90%; top: 70%; animation-delay: 1s;"></div>
|
||||
<div class="particle w-1 h-1" style="left: 30%; top: 10%; animation-delay: 3s;"></div>
|
||||
<div class="particle w-2 h-2" style="left: 70%; top: 90%; animation-delay: 5s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative">
|
||||
<!-- 로그인 영역 -->
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="text-center fade-in">
|
||||
<div class="mx-auto h-24 w-24 glass-effect rounded-full flex items-center justify-center mb-6 shadow-2xl">
|
||||
<i class="fas fa-book text-white text-4xl"></i>
|
||||
</div>
|
||||
<h2 class="text-4xl font-bold text-white mb-2 drop-shadow-lg">Document Server</h2>
|
||||
<p class="text-blue-100 text-lg">지식을 관리하고 공유하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 폼 -->
|
||||
<div class="glass-effect rounded-2xl shadow-2xl p-8 slide-up">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-semibold text-white text-center mb-2">로그인</h3>
|
||||
<p class="text-blue-100 text-center text-sm">계정에 로그인하여 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-500/20 text-green-100 border border-green-400/30' : 'bg-red-500/20 text-red-100 border border-red-400/30'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-400' : 'fas fa-exclamation-circle text-red-400'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="login()" class="space-y-6">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-blue-100 mb-2">
|
||||
<i class="fas fa-envelope mr-2"></i>이메일
|
||||
</label>
|
||||
<input type="email" id="email" x-model="loginForm.email" required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300"
|
||||
placeholder="이메일을 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-blue-100 mb-2">
|
||||
<i class="fas fa-lock mr-2"></i>비밀번호
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input :type="showPassword ? 'text' : 'password'" id="password" x-model="loginForm.password" required
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-blue-200 input-glow focus:outline-none focus:border-blue-400 transition-all duration-300 pr-12"
|
||||
placeholder="비밀번호를 입력하세요">
|
||||
<button type="button" @click="showPassword = !showPassword"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-blue-200 hover:text-white transition-colors">
|
||||
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 유지 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center text-sm text-blue-100">
|
||||
<input type="checkbox" x-model="loginForm.remember"
|
||||
class="mr-2 rounded bg-white/10 border-white/20 text-blue-500 focus:ring-blue-500 focus:ring-offset-0">
|
||||
로그인 상태 유지
|
||||
</label>
|
||||
<a href="#" class="text-sm text-blue-200 hover:text-white transition-colors">
|
||||
비밀번호를 잊으셨나요?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed btn-login transition-all duration-300">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="loading"></i>
|
||||
<i class="fas fa-sign-in-alt mr-2" x-show="!loading"></i>
|
||||
<span x-text="loading ? '로그인 중...' : '로그인'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 추가 옵션 -->
|
||||
<div class="mt-6 text-center">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/20"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-transparent text-blue-200">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button @click="goToSetup()" class="text-blue-200 hover:text-white text-sm transition-colors">
|
||||
<i class="fas fa-cog mr-1"></i>시스템 초기 설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="text-center text-blue-200 text-sm fade-in mt-8">
|
||||
<p>© 2024 Document Server. All rights reserved.</p>
|
||||
<p class="mt-1">
|
||||
<i class="fas fa-shield-alt mr-1"></i>
|
||||
안전하고 신뢰할 수 있는 문서 관리 시스템
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 배경 이미지 로드 스크립트 -->
|
||||
<script>
|
||||
// 배경 이미지 로드 시도
|
||||
const img = new Image();
|
||||
img.onload = function() {
|
||||
document.body.classList.remove('login-background-fallback');
|
||||
document.body.classList.add('login-background');
|
||||
};
|
||||
img.onerror = function() {
|
||||
console.log('배경 이미지를 찾을 수 없어 기본 그라디언트를 사용합니다.');
|
||||
};
|
||||
img.src = 'static/images/login-bg.jpg';
|
||||
</script>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
|
||||
<!-- 로그인 앱 스크립트 -->
|
||||
<script>
|
||||
function loginApp() {
|
||||
return {
|
||||
loading: false,
|
||||
showPassword: false,
|
||||
|
||||
loginForm: {
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔐 로그인 앱 초기화');
|
||||
|
||||
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
try {
|
||||
await api.getCurrentUser();
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
} catch (error) {
|
||||
// 토큰이 유효하지 않으면 제거
|
||||
localStorage.removeItem('access_token');
|
||||
}
|
||||
}
|
||||
|
||||
// URL 파라미터에서 리다이렉트 URL 확인
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.redirectUrl = urlParams.get('redirect') || 'index.html';
|
||||
},
|
||||
|
||||
async login() {
|
||||
if (!this.loginForm.email || !this.loginForm.password) {
|
||||
this.showNotification('이메일과 비밀번호를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
console.log('🔐 로그인 시도:', this.loginForm.email);
|
||||
|
||||
const result = await api.login(this.loginForm.email, this.loginForm.password);
|
||||
|
||||
if (result.success) {
|
||||
this.showNotification('로그인 성공! 페이지를 이동합니다...', 'success');
|
||||
|
||||
// 잠시 후 리다이렉트
|
||||
setTimeout(() => {
|
||||
window.location.href = this.redirectUrl || 'index.html';
|
||||
}, 1000);
|
||||
} else {
|
||||
this.showNotification(result.message || '로그인에 실패했습니다.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 로그인 오류:', error);
|
||||
this.showNotification('로그인 중 오류가 발생했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
goToSetup() {
|
||||
window.location.href = 'setup.html';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -780,7 +780,7 @@
|
||||
class="absolute tree-diagram-node"
|
||||
:style="getNodePosition(node)"
|
||||
:data-node-id="node.id"
|
||||
@mousedown="startDragNode($event, node)"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<div
|
||||
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
|
||||
@@ -788,7 +788,6 @@
|
||||
'tree-node-canonical': node.is_canonical,
|
||||
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
|
||||
}"
|
||||
@click="selectNode(node)"
|
||||
@dblclick="editNodeInline(node)"
|
||||
>
|
||||
<!-- 정사 경로 배지 -->
|
||||
|
||||
363
frontend/profile.html
Normal file
363
frontend/profile.html
Normal file
@@ -0,0 +1,363 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로필 관리 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="profileApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">프로필 관리</h1>
|
||||
<p class="text-gray-600">개인 정보와 계정 설정을 관리하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 카드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center space-x-6 mb-6">
|
||||
<!-- 프로필 아바타 -->
|
||||
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user text-blue-600 text-2xl"></i>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900" x-text="user.full_name || user.email"></h2>
|
||||
<p class="text-gray-600" x-text="user.email"></p>
|
||||
<div class="flex items-center mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
|
||||
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
|
||||
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
|
||||
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
|
||||
<span x-text="getRoleText(user.role)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="mb-8">
|
||||
<nav class="flex space-x-8" aria-label="Tabs">
|
||||
<button @click="activeTab = 'profile'"
|
||||
:class="activeTab === 'profile' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-user mr-2"></i>프로필 정보
|
||||
</button>
|
||||
<button @click="activeTab = 'security'"
|
||||
:class="activeTab === 'security' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-lock mr-2"></i>보안 설정
|
||||
</button>
|
||||
<button @click="activeTab = 'preferences'"
|
||||
:class="activeTab === 'preferences' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
|
||||
<i class="fas fa-cog mr-2"></i>환경 설정
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- 프로필 정보 탭 -->
|
||||
<div x-show="activeTab === 'profile'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">프로필 정보</h3>
|
||||
|
||||
<form @submit.prevent="updateProfile()" class="space-y-6">
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">이름</label>
|
||||
<input type="text" id="full_name" x-model="profileForm.full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input type="email" id="email" x-model="user.email" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
|
||||
<p class="mt-1 text-sm text-gray-500">이메일은 변경할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="profileLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="profileLoading"></i>
|
||||
<span x-text="profileLoading ? '저장 중...' : '프로필 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 보안 설정 탭 -->
|
||||
<div x-show="activeTab === 'security'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">비밀번호 변경</h3>
|
||||
|
||||
<form @submit.prevent="changePassword()" class="space-y-6">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">현재 비밀번호</label>
|
||||
<input type="password" id="current_password" x-model="passwordForm.current_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호</label>
|
||||
<input type="password" id="new_password" x-model="passwordForm.new_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required minlength="6">
|
||||
<p class="mt-1 text-sm text-gray-500">최소 6자 이상 입력해주세요.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">새 비밀번호 확인</label>
|
||||
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="passwordLoading || passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="passwordLoading"></i>
|
||||
<span x-text="passwordLoading ? '변경 중...' : '비밀번호 변경'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 환경 설정 탭 -->
|
||||
<div x-show="activeTab === 'preferences'" class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-6">환경 설정</h3>
|
||||
|
||||
<form @submit.prevent="updatePreferences()" class="space-y-6">
|
||||
<div>
|
||||
<label for="theme" class="block text-sm font-medium text-gray-700 mb-2">테마</label>
|
||||
<select id="theme" x-model="preferencesForm.theme"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="light">라이트 모드</option>
|
||||
<option value="dark">다크 모드</option>
|
||||
<option value="auto">시스템 설정 따름</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 mb-2">언어</label>
|
||||
<select id="language" x-model="preferencesForm.language"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700 mb-2">시간대</label>
|
||||
<select id="timezone" x-model="preferencesForm.timezone"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="Asia/Seoul">서울 (UTC+9)</option>
|
||||
<option value="UTC">UTC (UTC+0)</option>
|
||||
<option value="America/New_York">뉴욕 (UTC-5)</option>
|
||||
<option value="Europe/London">런던 (UTC+0)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="preferencesLoading"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="preferencesLoading"></i>
|
||||
<span x-text="preferencesLoading ? '저장 중...' : '설정 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
|
||||
<!-- 프로필 관리 스크립트 -->
|
||||
<script>
|
||||
function profileApp() {
|
||||
return {
|
||||
user: {},
|
||||
activeTab: 'profile',
|
||||
profileLoading: false,
|
||||
passwordLoading: false,
|
||||
preferencesLoading: false,
|
||||
|
||||
profileForm: {
|
||||
full_name: ''
|
||||
},
|
||||
|
||||
passwordForm: {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
},
|
||||
|
||||
preferencesForm: {
|
||||
theme: 'light',
|
||||
language: 'ko',
|
||||
timezone: 'Asia/Seoul'
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 프로필 앱 초기화');
|
||||
await this.loadUserProfile();
|
||||
},
|
||||
|
||||
async loadUserProfile() {
|
||||
try {
|
||||
const response = await api.get('/users/me');
|
||||
this.user = response;
|
||||
|
||||
// 폼 데이터 초기화
|
||||
this.profileForm.full_name = response.full_name || '';
|
||||
this.preferencesForm.theme = response.theme || 'light';
|
||||
this.preferencesForm.language = response.language || 'ko';
|
||||
this.preferencesForm.timezone = response.timezone || 'Asia/Seoul';
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(response);
|
||||
}
|
||||
|
||||
console.log('✅ 사용자 프로필 로드 완료:', response);
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 프로필 로드 실패:', error);
|
||||
this.showNotification('사용자 정보를 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async updateProfile() {
|
||||
this.profileLoading = true;
|
||||
try {
|
||||
const response = await api.put('/users/me', this.profileForm);
|
||||
this.user = { ...this.user, ...response };
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(this.user);
|
||||
}
|
||||
|
||||
this.showNotification('프로필이 성공적으로 업데이트되었습니다.', 'success');
|
||||
console.log('✅ 프로필 업데이트 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 프로필 업데이트 실패:', error);
|
||||
this.showNotification('프로필 업데이트에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.profileLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.showNotification('새 비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordLoading = true;
|
||||
try {
|
||||
await api.post('/users/me/change-password', {
|
||||
current_password: this.passwordForm.current_password,
|
||||
new_password: this.passwordForm.new_password
|
||||
});
|
||||
|
||||
// 폼 초기화
|
||||
this.passwordForm = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
};
|
||||
|
||||
this.showNotification('비밀번호가 성공적으로 변경되었습니다.', 'success');
|
||||
console.log('✅ 비밀번호 변경 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 비밀번호 변경 실패:', error);
|
||||
this.showNotification('비밀번호 변경에 실패했습니다. 현재 비밀번호를 확인해주세요.', 'error');
|
||||
} finally {
|
||||
this.passwordLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async updatePreferences() {
|
||||
this.preferencesLoading = true;
|
||||
try {
|
||||
const response = await api.put('/users/me', this.preferencesForm);
|
||||
this.user = { ...this.user, ...response };
|
||||
|
||||
this.showNotification('환경 설정이 성공적으로 저장되었습니다.', 'success');
|
||||
console.log('✅ 환경 설정 업데이트 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 환경 설정 업데이트 실패:', error);
|
||||
this.showNotification('환경 설정 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.preferencesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,9 @@
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- PDF.js 라이브러리 -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
@@ -251,6 +254,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 파일 타입 필터 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-700">파일 타입:</span>
|
||||
<button
|
||||
@click="fileTypeFilter = fileTypeFilter === 'PDF' ? '' : 'PDF'; applyFilters()"
|
||||
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
|
||||
:class="fileTypeFilter === 'PDF' ? 'bg-red-100 text-red-800 border-red-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-file-pdf mr-1"></i>PDF
|
||||
</button>
|
||||
<button
|
||||
@click="fileTypeFilter = fileTypeFilter === 'HTML' ? '' : 'HTML'; applyFilters()"
|
||||
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
|
||||
:class="fileTypeFilter === 'HTML' ? 'bg-orange-100 text-orange-800 border-orange-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<i class="fas fa-code mr-1"></i>HTML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 -->
|
||||
<div class="flex items-center space-x-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700">정렬:</span>
|
||||
@@ -314,7 +336,7 @@
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
|
||||
<template x-for="result in filteredResults" :key="result.id">
|
||||
<template x-for="result in filteredResults" :key="result.unique_id || result.id">
|
||||
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
|
||||
<!-- 결과 헤더 -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
@@ -323,6 +345,29 @@
|
||||
<span class="result-type-badge"
|
||||
:class="`badge-${result.type}`"
|
||||
x-text="getTypeLabel(result.type)"></span>
|
||||
|
||||
<!-- 파일 타입 정보 (PDF/HTML) -->
|
||||
<span x-show="result.highlight_info?.file_type"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="{
|
||||
'bg-red-100 text-red-800': result.highlight_info?.file_type === 'PDF',
|
||||
'bg-orange-100 text-orange-800': result.highlight_info?.file_type === 'HTML'
|
||||
}">
|
||||
<i class="fas mr-1"
|
||||
:class="{
|
||||
'fa-file-pdf': result.highlight_info?.file_type === 'PDF',
|
||||
'fa-code': result.highlight_info?.file_type === 'HTML'
|
||||
}"></i>
|
||||
<span x-text="result.highlight_info?.file_type"></span>
|
||||
</span>
|
||||
|
||||
<!-- 매치 개수 -->
|
||||
<span x-show="result.highlight_info && result.highlight_info.match_count > 0"
|
||||
class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
<span x-text="result.highlight_info?.match_count || 0"></span>개 매치
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
|
||||
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
|
||||
<i class="fas fa-star text-yellow-500 mr-1"></i>
|
||||
@@ -483,7 +528,8 @@
|
||||
<!-- PDF 뷰어 컨테이너 -->
|
||||
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
|
||||
<!-- PDF iframe 뷰어 -->
|
||||
<iframe x-show="!pdfError && !pdfLoading"
|
||||
<iframe id="pdf-preview-iframe"
|
||||
x-show="!pdfError && !pdfLoading"
|
||||
class="w-full h-full border-0"
|
||||
:src="pdfSrc"
|
||||
@load="pdfLoaded = true"
|
||||
|
||||
274
frontend/setup.html
Normal file
274
frontend/setup.html
Normal file
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>시스템 초기 설정 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen" x-data="setupApp()">
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 로고 및 제목 -->
|
||||
<div class="text-center">
|
||||
<div class="mx-auto h-20 w-20 bg-blue-600 rounded-full flex items-center justify-center mb-6">
|
||||
<i class="fas fa-book text-white text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">Document Server</h2>
|
||||
<p class="text-gray-600">시스템 초기 설정</p>
|
||||
</div>
|
||||
|
||||
<!-- 설정 상태 확인 중 -->
|
||||
<div x-show="loading" class="text-center">
|
||||
<div class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-blue-600 bg-white">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||
시스템 상태 확인 중...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 이미 설정된 시스템 -->
|
||||
<div x-show="!loading && !setupRequired" class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">시스템이 이미 설정되었습니다</h3>
|
||||
<p class="text-gray-600 mb-6">Document Server가 정상적으로 구성되어 있습니다.</p>
|
||||
<a href="index.html" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 초기 설정 폼 -->
|
||||
<div x-show="!loading && setupRequired" class="bg-white rounded-lg shadow-md p-8">
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">관리자 계정 생성</h3>
|
||||
<p class="text-gray-600">시스템 관리자(Root) 계정을 생성해주세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="initializeSystem()" class="space-y-6">
|
||||
<div>
|
||||
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-envelope mr-1"></i>관리자 이메일
|
||||
</label>
|
||||
<input type="email" id="admin_email" x-model="setupForm.admin_email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="admin@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-lock mr-1"></i>관리자 비밀번호
|
||||
</label>
|
||||
<input type="password" id="admin_password" x-model="setupForm.admin_password" required minlength="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="최소 6자 이상">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-lock mr-1"></i>비밀번호 확인
|
||||
</label>
|
||||
<input type="password" id="confirm_password" x-model="confirmPassword" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="비밀번호를 다시 입력하세요">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_full_name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-user mr-1"></i>관리자 이름 (선택사항)
|
||||
</label>
|
||||
<input type="text" id="admin_full_name" x-model="setupForm.admin_full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="시스템 관리자">
|
||||
</div>
|
||||
|
||||
<!-- 주의사항 -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
|
||||
<div class="text-sm text-yellow-800">
|
||||
<p class="font-medium mb-1">주의사항:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>이 계정은 시스템의 최고 관리자 권한을 가집니다.</li>
|
||||
<li>안전한 비밀번호를 사용하고 잘 보관해주세요.</li>
|
||||
<li>설정 완료 후에는 이 페이지에 다시 접근할 수 없습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="setupLoading || setupForm.admin_password !== confirmPassword"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="setupLoading"></i>
|
||||
<i class="fas fa-rocket mr-2" x-show="!setupLoading"></i>
|
||||
<span x-text="setupLoading ? '설정 중...' : '시스템 초기화'"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 설정 완료 -->
|
||||
<div x-show="setupComplete" class="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">설정이 완료되었습니다!</h3>
|
||||
<p class="text-gray-600 mb-6">Document Server가 성공적으로 초기화되었습니다.</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div class="text-sm text-blue-800">
|
||||
<p class="font-medium mb-2">생성된 관리자 계정:</p>
|
||||
<p><strong>이메일:</strong> <span x-text="createdAdmin.email"></span></p>
|
||||
<p><strong>이름:</strong> <span x-text="createdAdmin.full_name"></span></p>
|
||||
<p><strong>역할:</strong> 시스템 관리자</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="index.html" class="w-full inline-flex justify-center items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-home mr-2"></i>메인 페이지로 이동
|
||||
</a>
|
||||
<button @click="goToLogin()" class="w-full inline-flex justify-center items-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>로그인하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script>
|
||||
// 간단한 API 클라이언트
|
||||
const setupApi = {
|
||||
async get(endpoint) {
|
||||
const response = await fetch(`/api${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async post(endpoint, data) {
|
||||
const response = await fetch(`/api${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- 설정 앱 스크립트 -->
|
||||
<script>
|
||||
function setupApp() {
|
||||
return {
|
||||
loading: true,
|
||||
setupRequired: false,
|
||||
setupComplete: false,
|
||||
setupLoading: false,
|
||||
confirmPassword: '',
|
||||
createdAdmin: {},
|
||||
|
||||
setupForm: {
|
||||
admin_email: '',
|
||||
admin_password: '',
|
||||
admin_full_name: ''
|
||||
},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 설정 앱 초기화');
|
||||
await this.checkSetupStatus();
|
||||
},
|
||||
|
||||
async checkSetupStatus() {
|
||||
try {
|
||||
const status = await setupApi.get('/setup/status');
|
||||
this.setupRequired = status.is_setup_required;
|
||||
|
||||
console.log('✅ 설정 상태 확인 완료:', status);
|
||||
} catch (error) {
|
||||
console.error('❌ 설정 상태 확인 실패:', error);
|
||||
this.showNotification('시스템 상태를 확인할 수 없습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async initializeSystem() {
|
||||
if (this.setupForm.admin_password !== this.confirmPassword) {
|
||||
this.showNotification('비밀번호가 일치하지 않습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupLoading = true;
|
||||
try {
|
||||
const result = await setupApi.post('/setup/initialize', this.setupForm);
|
||||
|
||||
this.createdAdmin = result.admin_user;
|
||||
this.setupComplete = true;
|
||||
this.setupRequired = false;
|
||||
|
||||
console.log('✅ 시스템 초기화 완료:', result);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 초기화 실패:', error);
|
||||
this.showNotification(error.message || '시스템 초기화에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.setupLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
goToLogin() {
|
||||
// 로그인 모달을 열거나 로그인 페이지로 이동
|
||||
window.location.href = 'index.html';
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 메인 스타일 */
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
|
||||
}
|
||||
|
||||
/* 알림 애니메이션 */
|
||||
|
||||
41
frontend/static/images/README.md
Normal file
41
frontend/static/images/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 로그인 페이지 이미지
|
||||
|
||||
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
|
||||
|
||||
## 필요한 이미지 파일
|
||||
|
||||
### 배경 이미지
|
||||
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
|
||||
|
||||
## 이미지 사양
|
||||
|
||||
- **형식**: JPG, PNG 지원
|
||||
- **품질**: 웹 최적화된 고품질 이미지
|
||||
- **용량**: 1MB 이하 권장
|
||||
- **비율**: 16:9 또는 16:10 비율 권장
|
||||
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
|
||||
|
||||
## 폴백 동작
|
||||
|
||||
배경 이미지 파일이 없는 경우:
|
||||
- 파란색-보라색 그라디언트 배경으로 자동 폴백
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```
|
||||
static/images/
|
||||
└── login-bg.jpg (전체 배경)
|
||||
```
|
||||
|
||||
## 배경 이미지 선택 가이드
|
||||
|
||||
- **문서/도서관 테마**: 책장, 도서관, 서재 등
|
||||
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
|
||||
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
|
||||
- **미니멀 테마**: 단순한 패턴, 텍스처
|
||||
|
||||
## 변경 사항 (v2.0)
|
||||
|
||||
- 갤러리 액자 기능 제거
|
||||
- 중앙 집중형 로그인 레이아웃으로 변경
|
||||
- 배경 이미지만 사용하는 심플한 디자인
|
||||
BIN
frontend/static/images/login-bg-2.jpg
Normal file
BIN
frontend/static/images/login-bg-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
frontend/static/images/login-bg-3.jpg
Normal file
BIN
frontend/static/images/login-bg-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 KiB |
BIN
frontend/static/images/login-bg.jpg
Normal file
BIN
frontend/static/images/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 690 KiB |
@@ -201,7 +201,7 @@ class DocumentServerAPI {
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
return await this.get('/auth/me');
|
||||
return await this.get('/users/me');
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken) {
|
||||
|
||||
92
frontend/static/js/auth-guard.js
Normal file
92
frontend/static/js/auth-guard.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 인증 가드 - 모든 보호된 페이지에서 사용
|
||||
* 로그인하지 않은 사용자를 자동으로 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// 인증이 필요하지 않은 페이지들
|
||||
const PUBLIC_PAGES = [
|
||||
'login.html',
|
||||
'setup.html'
|
||||
];
|
||||
|
||||
// 현재 페이지가 공개 페이지인지 확인
|
||||
function isPublicPage() {
|
||||
const currentPath = window.location.pathname;
|
||||
return PUBLIC_PAGES.some(page => currentPath.includes(page));
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
function redirectToLogin() {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
console.log('🔐 인증되지 않은 접근. 로그인 페이지로 이동합니다.');
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
}
|
||||
|
||||
// 인증 체크 함수
|
||||
async function checkAuthentication() {
|
||||
// 공개 페이지는 체크하지 않음
|
||||
if (isPublicPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
// 토큰이 없으면 즉시 리다이렉트
|
||||
if (!token) {
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 유효성 검사
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('🔐 토큰이 유효하지 않습니다. 상태:', response.status);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증 성공
|
||||
const user = await response.json();
|
||||
console.log('✅ 인증 성공:', user.email);
|
||||
|
||||
// 전역 사용자 정보 설정
|
||||
window.currentUser = user;
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(user);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('🔐 인증 확인 중 오류:', error);
|
||||
localStorage.removeItem('access_token');
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
// DOM 로드 완료 전에 인증 체크 실행
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', checkAuthentication);
|
||||
} else {
|
||||
checkAuthentication();
|
||||
}
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.authGuard = {
|
||||
checkAuthentication,
|
||||
redirectToLogin,
|
||||
isPublicPage
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -155,9 +155,97 @@ document.addEventListener('headerLoaded', () => {
|
||||
setTimeout(() => {
|
||||
window.headerLoader.updateActiveStates();
|
||||
|
||||
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(null);
|
||||
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
|
||||
if (typeof window.updateUserMenu === 'undefined') {
|
||||
window.updateUserMenu = (user) => {
|
||||
console.log('🔄 updateUserMenu 호출됨:', user);
|
||||
|
||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||
const loginButton = document.getElementById('login-button');
|
||||
const adminMenuSection = document.getElementById('admin-menu-section');
|
||||
|
||||
// 사용자 정보 요소들
|
||||
const userName = document.getElementById('user-name');
|
||||
const userRole = document.getElementById('user-role');
|
||||
const dropdownUserName = document.getElementById('dropdown-user-name');
|
||||
const dropdownUserEmail = document.getElementById('dropdown-user-email');
|
||||
const dropdownUserRole = document.getElementById('dropdown-user-role');
|
||||
|
||||
if (user) {
|
||||
// 로그인된 상태
|
||||
console.log('✅ 사용자 로그인 상태 - UI 업데이트');
|
||||
if (loggedInMenu) {
|
||||
loggedInMenu.classList.remove('hidden');
|
||||
console.log('✅ 로그인 메뉴 표시');
|
||||
}
|
||||
if (loginButton) {
|
||||
loginButton.classList.add('hidden');
|
||||
console.log('✅ 로그인 버튼 숨김');
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
const displayName = user.full_name || user.email || 'User';
|
||||
const roleText = user.role === 'root' ? '시스템 관리자' :
|
||||
user.role === 'admin' ? '관리자' : '사용자';
|
||||
|
||||
if (userName) userName.textContent = displayName;
|
||||
if (userRole) userRole.textContent = roleText;
|
||||
if (dropdownUserName) dropdownUserName.textContent = displayName;
|
||||
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
|
||||
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
|
||||
|
||||
// 관리자 메뉴 표시/숨김
|
||||
if (adminMenuSection) {
|
||||
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
|
||||
adminMenuSection.classList.remove('hidden');
|
||||
} else {
|
||||
adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 로그아웃된 상태
|
||||
console.log('❌ 로그아웃 상태');
|
||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||
if (loginButton) loginButton.classList.remove('hidden');
|
||||
if (adminMenuSection) adminMenuSection.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
console.log('✅ updateUserMenu 함수 정의 완료');
|
||||
}
|
||||
|
||||
// 사용자 메뉴 상태 설정 (현재 로그인 상태 확인)
|
||||
setTimeout(() => {
|
||||
// 전역 사용자 정보가 있으면 사용, 없으면 토큰으로 확인
|
||||
if (window.currentUser) {
|
||||
window.updateUserMenu(window.currentUser);
|
||||
} else {
|
||||
// 토큰이 있으면 사용자 정보 다시 가져오기
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(user => {
|
||||
if (user) {
|
||||
window.currentUser = user;
|
||||
window.updateUserMenu(user);
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
})
|
||||
.catch(() => window.updateUserMenu(null));
|
||||
} else {
|
||||
window.updateUserMenu(null);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// 전역 함수들이 정의되지 않은 경우 빈 함수로 초기화
|
||||
if (typeof window.handleLanguageChange === 'undefined') {
|
||||
window.handleLanguageChange = function(lang) {
|
||||
console.log('언어 변경 함수가 아직 로드되지 않았습니다:', lang);
|
||||
};
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,15 @@ window.documentApp = () => ({
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
// 로그인 페이지로 리다이렉트 (setup.html 제외)
|
||||
if (!window.location.pathname.includes('setup.html') &&
|
||||
!window.location.pathname.includes('login.html')) {
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
},
|
||||
|
||||
@@ -63,9 +63,6 @@ window.memoTreeApp = function() {
|
||||
treePanX: 0,
|
||||
treePanY: 0,
|
||||
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
@@ -290,7 +287,15 @@ window.memoTreeApp = function() {
|
||||
|
||||
const node = await window.api.createMemoNode(nodeData);
|
||||
this.treeNodes.push(node);
|
||||
this.selectNode(node);
|
||||
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 루트 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -301,6 +306,11 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 선택
|
||||
selectNode(node) {
|
||||
// 현재 팬 값 저장 (위치 변경 방지)
|
||||
const currentPanX = this.treePanX;
|
||||
const currentPanY = this.treePanY;
|
||||
const currentZoom = this.treeZoom;
|
||||
|
||||
// 이전 노드 저장
|
||||
if (this.selectedNode && this.isEditorDirty) {
|
||||
this.saveNode();
|
||||
@@ -323,6 +333,11 @@ window.memoTreeApp = function() {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 팬 값 복원 (위치 변경 방지)
|
||||
this.treePanX = currentPanX;
|
||||
this.treePanY = currentPanY;
|
||||
this.treeZoom = currentZoom;
|
||||
|
||||
console.log('📝 노드 선택:', node.title);
|
||||
},
|
||||
|
||||
@@ -552,8 +567,14 @@ window.memoTreeApp = function() {
|
||||
// 부모 노드 펼치기
|
||||
this.expandedNodes.add(parentNode.id);
|
||||
|
||||
// 새 노드 선택
|
||||
this.selectNode(node);
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 자식 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -623,123 +644,9 @@ window.memoTreeApp = function() {
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 시작
|
||||
startDragNode(event, node) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
console.log('🎯 노드 드래그 시작:', node.title);
|
||||
|
||||
this.isDragging = true;
|
||||
this.dragNode = node;
|
||||
|
||||
// 드래그 시작 위치 계산
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
};
|
||||
|
||||
// 드래그 중인 노드 스타일 적용
|
||||
event.target.style.opacity = '0.7';
|
||||
event.target.style.transform = 'scale(1.05)';
|
||||
event.target.style.zIndex = '1000';
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
document.addEventListener('mousemove', this.handleNodeDrag.bind(this));
|
||||
document.addEventListener('mouseup', this.endNodeDrag.bind(this));
|
||||
},
|
||||
|
||||
// 노드 드래그 처리
|
||||
handleNodeDrag(event) {
|
||||
if (!this.isDragging || !this.dragNode) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// 드래그 중인 노드 위치 업데이트
|
||||
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
|
||||
if (dragElement) {
|
||||
const newX = event.clientX - this.dragOffset.x;
|
||||
const newY = event.clientY - this.dragOffset.y;
|
||||
|
||||
dragElement.style.position = 'fixed';
|
||||
dragElement.style.left = `${newX}px`;
|
||||
dragElement.style.top = `${newY}px`;
|
||||
dragElement.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
// 드롭 대상 하이라이트
|
||||
this.highlightDropTarget(event);
|
||||
},
|
||||
|
||||
// 드롭 대상 하이라이트
|
||||
highlightDropTarget(event) {
|
||||
// 모든 노드에서 하이라이트 제거
|
||||
document.querySelectorAll('.tree-diagram-node').forEach(el => {
|
||||
el.classList.remove('drop-target-highlight');
|
||||
});
|
||||
|
||||
// 현재 마우스 위치의 노드 찾기
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const targetNode = elements.find(el =>
|
||||
el.classList.contains('tree-diagram-node') &&
|
||||
el.getAttribute('data-node-id') !== this.dragNode.id
|
||||
);
|
||||
|
||||
if (targetNode) {
|
||||
targetNode.classList.add('drop-target-highlight');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 종료
|
||||
endNodeDrag(event) {
|
||||
if (!this.isDragging || !this.dragNode) return;
|
||||
|
||||
console.log('🎯 노드 드래그 종료');
|
||||
|
||||
// 드롭 대상 찾기
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const targetElement = elements.find(el =>
|
||||
el.classList.contains('tree-diagram-node') &&
|
||||
el.getAttribute('data-node-id') !== this.dragNode.id
|
||||
);
|
||||
|
||||
let targetNodeId = null;
|
||||
if (targetElement) {
|
||||
targetNodeId = targetElement.getAttribute('data-node-id');
|
||||
}
|
||||
|
||||
// 드래그 중인 노드 스타일 복원
|
||||
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
|
||||
if (dragElement) {
|
||||
dragElement.style.opacity = '';
|
||||
dragElement.style.transform = '';
|
||||
dragElement.style.zIndex = '';
|
||||
dragElement.style.position = '';
|
||||
dragElement.style.left = '';
|
||||
dragElement.style.top = '';
|
||||
dragElement.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
// 모든 하이라이트 제거
|
||||
document.querySelectorAll('.tree-diagram-node').forEach(el => {
|
||||
el.classList.remove('drop-target-highlight');
|
||||
});
|
||||
|
||||
// 실제 노드 이동 처리
|
||||
if (targetNodeId && targetNodeId !== this.dragNode.id) {
|
||||
this.moveNodeToParent(this.dragNode.id, targetNodeId);
|
||||
}
|
||||
|
||||
// 상태 초기화
|
||||
this.isDragging = false;
|
||||
this.dragNode = null;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
document.removeEventListener('mousemove', this.handleNodeDrag);
|
||||
document.removeEventListener('mouseup', this.endNodeDrag);
|
||||
},
|
||||
|
||||
// 노드를 다른 부모로 이동
|
||||
async moveNodeToParent(nodeId, newParentId) {
|
||||
@@ -783,15 +690,12 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 위치 계산 및 반환
|
||||
getNodePosition(node) {
|
||||
if (!this.nodePositions.has(node.id)) {
|
||||
this.calculateNodePositions();
|
||||
}
|
||||
|
||||
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
|
||||
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
||||
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
||||
},
|
||||
|
||||
// 트리 노드 위치 자동 계산
|
||||
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
|
||||
calculateNodePositions() {
|
||||
const canvas = document.getElementById('tree-canvas');
|
||||
if (!canvas) return;
|
||||
@@ -802,11 +706,11 @@ window.memoTreeApp = function() {
|
||||
// 노드 크기 설정
|
||||
const nodeWidth = 200;
|
||||
const nodeHeight = 80;
|
||||
const levelHeight = 150; // 레벨 간 간격
|
||||
const nodeSpacing = 50; // 노드 간 간격
|
||||
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
|
||||
const nodeSpacing = 100; // 노드 간 세로 간격
|
||||
const margin = 100; // 여백
|
||||
|
||||
// 레벨별 노드 그룹화
|
||||
// 레벨별 노드 그룹화 (가로 방향)
|
||||
const levels = new Map();
|
||||
|
||||
// 루트 노드들 찾기
|
||||
@@ -814,7 +718,7 @@ window.memoTreeApp = function() {
|
||||
|
||||
if (rootNodes.length === 0) return;
|
||||
|
||||
// BFS로 레벨별 노드 배치
|
||||
// BFS로 레벨별 노드 배치 (가로 방향)
|
||||
const queue = [];
|
||||
rootNodes.forEach(node => {
|
||||
queue.push({ node, level: 0 });
|
||||
@@ -835,25 +739,25 @@ window.memoTreeApp = function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 트리 전체 크기 계산
|
||||
// 트리 전체 크기 계산 (가로 방향)
|
||||
const maxLevel = Math.max(...levels.keys());
|
||||
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
||||
|
||||
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
|
||||
const treeHeight = (maxLevel + 1) * levelHeight;
|
||||
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
|
||||
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
|
||||
|
||||
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
||||
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
||||
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
||||
|
||||
// 각 레벨의 노드들 위치 계산
|
||||
// 각 레벨의 노드들 위치 계산 (가로 방향)
|
||||
levels.forEach((nodes, level) => {
|
||||
const y = offsetY + level * levelHeight;
|
||||
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = offsetX + (treeWidth - levelWidth) / 2;
|
||||
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
|
||||
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
|
||||
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
||||
const y = startY + index * (nodeHeight + nodeSpacing);
|
||||
this.nodePositions.set(node.id, { x, y });
|
||||
});
|
||||
});
|
||||
@@ -893,17 +797,17 @@ window.memoTreeApp = function() {
|
||||
// 연결선 생성
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
|
||||
// 부모 노드 하단 중앙에서 시작
|
||||
const startX = parentPos.x + 100; // 노드 중앙
|
||||
const startY = parentPos.y + 80; // 노드 하단
|
||||
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
|
||||
const startX = parentPos.x + 200; // 노드 오른쪽 끝
|
||||
const startY = parentPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 자식 노드 상단 중앙으로 연결
|
||||
const endX = childPos.x + 100; // 노드 중앙
|
||||
const endY = childPos.y; // 노드 상단
|
||||
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
|
||||
const endX = childPos.x; // 노드 왼쪽 끝
|
||||
const endY = childPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 곡선 경로 생성 (베지어 곡선)
|
||||
const midY = startY + (endY - startY) / 2;
|
||||
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
|
||||
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
|
||||
const midX = startX + (endX - startX) / 2;
|
||||
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
|
||||
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('stroke', '#9CA3AF');
|
||||
|
||||
@@ -15,6 +15,7 @@ window.searchApp = function() {
|
||||
|
||||
// 필터링
|
||||
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
||||
fileTypeFilter: '', // '', 'PDF', 'HTML'
|
||||
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
||||
|
||||
// 검색 디바운스
|
||||
@@ -157,9 +158,43 @@ window.searchApp = function() {
|
||||
applyFilters() {
|
||||
let results = [...this.searchResults];
|
||||
|
||||
// 중복 ID 제거 (같은 문서의 document와 document_content가 중복될 수 있음)
|
||||
const uniqueResults = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
results.forEach(result => {
|
||||
const uniqueKey = `${result.type}-${result.id}`;
|
||||
if (!seenIds.has(uniqueKey)) {
|
||||
seenIds.add(uniqueKey);
|
||||
uniqueResults.push({
|
||||
...result,
|
||||
unique_id: uniqueKey // Alpine.js x-for 키로 사용
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
results = uniqueResults;
|
||||
|
||||
// 타입 필터
|
||||
if (this.typeFilter) {
|
||||
results = results.filter(result => result.type === this.typeFilter);
|
||||
results = results.filter(result => {
|
||||
// 문서 타입은 document와 document_content 모두 포함
|
||||
if (this.typeFilter === 'document') {
|
||||
return result.type === 'document' || result.type === 'document_content';
|
||||
}
|
||||
// 하이라이트 타입은 highlight와 highlight_note 모두 포함
|
||||
if (this.typeFilter === 'highlight') {
|
||||
return result.type === 'highlight' || result.type === 'highlight_note';
|
||||
}
|
||||
return result.type === this.typeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 타입 필터
|
||||
if (this.fileTypeFilter) {
|
||||
results = results.filter(result => {
|
||||
return result.highlight_info?.file_type === this.fileTypeFilter;
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬
|
||||
@@ -179,7 +214,7 @@ window.searchApp = function() {
|
||||
});
|
||||
|
||||
this.filteredResults = results;
|
||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
|
||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
|
||||
},
|
||||
|
||||
// URL 업데이트
|
||||
@@ -339,22 +374,23 @@ window.searchApp = function() {
|
||||
this.pdfLoaded = false;
|
||||
|
||||
try {
|
||||
// PDF 파일 존재 여부 먼저 확인
|
||||
const response = await fetch(`/api/documents/${documentId}/pdf`, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 토큰 디버깅:', {
|
||||
token: token,
|
||||
tokenType: typeof token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
isNull: token === null,
|
||||
isStringNull: token === 'null',
|
||||
localStorage: Object.keys(localStorage)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// PDF 파일이 존재하면 src 설정
|
||||
const token = localStorage.getItem('token');
|
||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||
} else {
|
||||
throw new Error(`PDF 파일을 찾을 수 없습니다 (${response.status})`);
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||
} catch (error) {
|
||||
console.error('PDF 미리보기 로드 실패:', error);
|
||||
this.pdfError = true;
|
||||
@@ -370,6 +406,50 @@ window.searchApp = function() {
|
||||
this.pdfLoading = false;
|
||||
},
|
||||
|
||||
// PDF에서 검색어 찾기 (브라우저 내장 검색 활용)
|
||||
searchInPdf() {
|
||||
if (this.searchQuery && this.pdfLoaded) {
|
||||
// iframe 내에서 검색 실행 (Ctrl+F 시뮬레이션)
|
||||
const iframe = document.querySelector('#pdf-preview-iframe');
|
||||
if (iframe && iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.focus();
|
||||
// 브라우저 검색 창 열기 시도
|
||||
if (iframe.contentWindow.find) {
|
||||
iframe.contentWindow.find(this.searchQuery);
|
||||
} else {
|
||||
// 대안: 사용자에게 수동 검색 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
// 보안상 직접 접근이 안 되는 경우, 사용자에게 안내
|
||||
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 알림 표시 (간단한 토스트)
|
||||
showNotification(message, type = 'info') {
|
||||
// 간단한 알림 구현 (실제로는 더 정교한 토스트 시스템을 사용할 수 있음)
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
|
||||
type === 'info' ? 'bg-blue-500' :
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-gray-500'
|
||||
}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 3초 후 자동 제거
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
// HTML 미리보기 로드
|
||||
async loadHtmlPreview(documentId) {
|
||||
this.htmlLoading = true;
|
||||
@@ -385,7 +465,12 @@ window.searchApp = function() {
|
||||
const iframe = document.getElementById('htmlPreviewFrame');
|
||||
if (iframe) {
|
||||
// iframe src를 직접 설정 (인증 헤더 포함)
|
||||
const token = localStorage.getItem('token');
|
||||
const token = localStorage.getItem('access_token');
|
||||
console.log('🔍 HTML 미리보기 토큰:', token ? '있음' : '없음', token);
|
||||
if (!token || token === 'null' || token === null) {
|
||||
console.error('❌ HTML 미리보기 토큰 문제:', token);
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
|
||||
|
||||
// iframe 로드 완료 후 검색어 하이라이트
|
||||
|
||||
368
frontend/system-settings.html
Normal file
368
frontend/system-settings.html
Normal file
@@ -0,0 +1,368 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>시스템 설정 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 메인 앱 -->
|
||||
<div x-data="systemSettingsApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8 max-w-6xl">
|
||||
<!-- 권한 확인 중 로딩 -->
|
||||
<div x-show="!permissionChecked" class="flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
|
||||
<p class="text-gray-600">권한을 확인하고 있습니다...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권한 없음 -->
|
||||
<div x-show="permissionChecked && !hasPermission" class="text-center py-20">
|
||||
<div class="bg-red-100 border border-red-200 rounded-lg p-8 max-w-md mx-auto">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-600 mb-4"></i>
|
||||
<h2 class="text-xl font-semibold text-red-800 mb-2">접근 권한이 없습니다</h2>
|
||||
<p class="text-red-600 mb-4">시스템 설정에 접근하려면 관리자 권한이 필요합니다.</p>
|
||||
<button onclick="history.back()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 설정 (권한 있는 경우만 표시) -->
|
||||
<div x-show="permissionChecked && hasPermission">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-server text-2xl text-blue-600"></i>
|
||||
<h1 class="text-3xl font-bold text-gray-900">시스템 설정</h1>
|
||||
</div>
|
||||
<p class="text-gray-600">시스템 전체 설정을 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-600' : 'fas fa-exclamation-circle text-red-600'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설정 섹션들 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 시스템 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-info-circle text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">시스템 정보</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-users text-blue-600"></i>
|
||||
<span class="font-medium text-gray-700">총 사용자 수</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.totalUsers">-</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-user-shield text-green-600"></i>
|
||||
<span class="font-medium text-gray-700">활성 사용자</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.activeUsers">-</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-crown text-yellow-600"></i>
|
||||
<span class="font-medium text-gray-700">관리자 수</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.adminUsers">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 설정 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-cog text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">기본 설정</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateSystemSettings()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">시스템 이름</label>
|
||||
<input type="text" x-model="settings.systemName"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Document Server">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">기본 언어</label>
|
||||
<select x-model="settings.defaultLanguage"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="ko">한국어</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">기본 테마</label>
|
||||
<select x-model="settings.defaultTheme"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="light">라이트</option>
|
||||
<option value="dark">다크</option>
|
||||
<option value="auto">자동</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">기본 세션 타임아웃 (분)</label>
|
||||
<select x-model="settings.defaultSessionTimeout"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="5">5분</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<i class="fas fa-save"></i>
|
||||
<span x-text="loading ? '저장 중...' : '설정 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 관리 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-users-cog text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">사용자 관리</h2>
|
||||
</div>
|
||||
<a href="user-management.html" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center space-x-2">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<span>사용자 관리</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-600">
|
||||
<p class="mb-2">• 새로운 사용자 계정 생성 및 관리</p>
|
||||
<p class="mb-2">• 사용자 권한 설정 (서적관리, 노트관리, 소설관리)</p>
|
||||
<p class="mb-2">• 개별 사용자 세션 타임아웃 설정</p>
|
||||
<p>• 사용자 계정 활성화/비활성화</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 유지보수 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-tools text-xl text-orange-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">시스템 유지보수</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-900 mb-2">캐시 정리</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">시스템 캐시를 정리하여 성능을 개선합니다.</p>
|
||||
<button @click="clearCache()" :disabled="loading"
|
||||
class="w-full px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
<i class="fas fa-broom mr-2"></i>캐시 정리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-900 mb-2">시스템 재시작</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">시스템을 재시작하여 설정을 적용합니다.</p>
|
||||
<button @click="restartSystem()" :disabled="loading"
|
||||
class="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<i class="fas fa-power-off mr-2"></i>시스템 재시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
|
||||
<!-- 시스템 설정 스크립트 -->
|
||||
<script>
|
||||
function systemSettingsApp() {
|
||||
return {
|
||||
loading: false,
|
||||
permissionChecked: false,
|
||||
hasPermission: false,
|
||||
currentUser: null,
|
||||
systemInfo: {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
adminUsers: 0
|
||||
},
|
||||
settings: {
|
||||
systemName: 'Document Server',
|
||||
defaultLanguage: 'ko',
|
||||
defaultTheme: 'light',
|
||||
defaultSessionTimeout: 5
|
||||
},
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success'
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 시스템 설정 앱 초기화');
|
||||
await this.checkPermission();
|
||||
if (this.hasPermission) {
|
||||
await this.loadSystemInfo();
|
||||
await this.loadSettings();
|
||||
}
|
||||
},
|
||||
|
||||
async checkPermission() {
|
||||
try {
|
||||
this.currentUser = await api.getCurrentUser();
|
||||
// root, admin 역할이거나 is_admin이 true인 경우 권한 허용
|
||||
this.hasPermission = this.currentUser.role === 'root' ||
|
||||
this.currentUser.role === 'admin' ||
|
||||
this.currentUser.is_admin === true;
|
||||
|
||||
console.log('👤 현재 사용자:', this.currentUser);
|
||||
console.log('🔐 관리자 권한:', this.hasPermission);
|
||||
} catch (error) {
|
||||
console.error('❌ 권한 확인 실패:', error);
|
||||
this.hasPermission = false;
|
||||
} finally {
|
||||
this.permissionChecked = true;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSystemInfo() {
|
||||
try {
|
||||
const users = await api.get('/users');
|
||||
this.systemInfo.totalUsers = users.length;
|
||||
this.systemInfo.activeUsers = users.filter(u => u.is_active).length;
|
||||
this.systemInfo.adminUsers = users.filter(u => u.role === 'root' || u.role === 'admin' || u.is_admin).length;
|
||||
|
||||
console.log('📊 시스템 정보 로드 완료:', this.systemInfo);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 정보 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
// 실제로는 백엔드에서 시스템 설정을 가져와야 하지만,
|
||||
// 현재는 기본값을 사용
|
||||
console.log('⚙️ 시스템 설정 로드 (기본값 사용)');
|
||||
},
|
||||
|
||||
async updateSystemSettings() {
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('💾 시스템 설정 저장:', this.settings);
|
||||
|
||||
// 시뮬레이션을 위한 지연
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.showNotification('시스템 설정이 성공적으로 저장되었습니다.', 'success');
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 설정 저장 실패:', error);
|
||||
this.showNotification('시스템 설정 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async clearCache() {
|
||||
if (!confirm('시스템 캐시를 정리하시겠습니까?')) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('🧹 캐시 정리 중...');
|
||||
|
||||
// 시뮬레이션을 위한 지연
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
this.showNotification('캐시가 성공적으로 정리되었습니다.', 'success');
|
||||
} catch (error) {
|
||||
console.error('❌ 캐시 정리 실패:', error);
|
||||
this.showNotification('캐시 정리에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async restartSystem() {
|
||||
if (!confirm('시스템을 재시작하시겠습니까? 모든 사용자의 연결이 끊어집니다.')) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('🔄 시스템 재시작 중...');
|
||||
|
||||
this.showNotification('시스템 재시작이 요청되었습니다. 잠시 후 페이지가 새로고침됩니다.', 'success');
|
||||
|
||||
// 시뮬레이션을 위한 지연 후 페이지 새로고침
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 재시작 실패:', error);
|
||||
this.showNotification('시스템 재시작에 실패했습니다.', 'error');
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
520
frontend/user-management.html
Normal file
520
frontend/user-management.html
Normal file
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>사용자 관리 - Document Server</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<script src="static/js/auth-guard.js"></script>
|
||||
|
||||
<!-- 공통 스타일 -->
|
||||
<link rel="stylesheet" href="static/css/common.css">
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="userManagementApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">사용자 관리</h1>
|
||||
<p class="text-gray-600">시스템 사용자를 관리하고 권한을 설정하세요.</p>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>새 사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
<div x-show="notification.show" x-transition class="mb-6 p-4 rounded-lg" :class="notification.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-lg font-medium text-gray-900">사용자 목록</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
총 <span x-text="users.length"></span>명의 사용자
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 테이블 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">사용자</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">역할</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">권한</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">상태</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">가입일</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-for="user in users" :key="user.id">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center mr-4">
|
||||
<i class="fas fa-user text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900" x-text="user.full_name || user.email"></div>
|
||||
<div class="text-sm text-gray-500" x-text="user.email"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.role === 'root' ? 'bg-red-100 text-red-800' : user.role === 'admin' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'">
|
||||
<i class="fas fa-crown mr-1" x-show="user.role === 'root'"></i>
|
||||
<i class="fas fa-shield-alt mr-1" x-show="user.role === 'admin'"></i>
|
||||
<i class="fas fa-user mr-1" x-show="user.role === 'user'"></i>
|
||||
<span x-text="getRoleText(user.role)"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex space-x-1">
|
||||
<span x-show="user.can_manage_books" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
<i class="fas fa-book mr-1"></i>서적
|
||||
</span>
|
||||
<span x-show="user.can_manage_notes" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<i class="fas fa-sticky-note mr-1"></i>노트
|
||||
</span>
|
||||
<span x-show="user.can_manage_novels" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<i class="fas fa-feather-alt mr-1"></i>소설
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
|
||||
<i :class="user.is_active ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
|
||||
<span x-text="user.is_active ? '활성' : '비활성'"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span x-text="formatDate(user.created_at)"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex space-x-2">
|
||||
<button @click="editUser(user)" class="text-blue-600 hover:text-blue-900">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click="confirmDeleteUser(user)"
|
||||
x-show="user.role !== 'root'"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 생성 모달 -->
|
||||
<div x-show="showCreateModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">새 사용자 추가</h3>
|
||||
|
||||
<form @submit.prevent="createUser()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" x-model="createForm.email" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input type="password" x-model="createForm.password" required minlength="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input type="text" x-model="createForm.full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">역할</label>
|
||||
<select x-model="createForm.role"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="user">사용자</option>
|
||||
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
|
||||
<select x-model="createForm.session_timeout_minutes"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="5">5분 (기본)</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="120">2시간</option>
|
||||
<option value="480">8시간</option>
|
||||
<option value="1440">24시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_books" class="mr-2">
|
||||
<span class="text-sm">서적 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_notes" class="mr-2">
|
||||
<span class="text-sm">노트 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_novels" class="mr-2">
|
||||
<span class="text-sm">소설 관리</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="showCreateModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" :disabled="createLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="createLoading"></i>
|
||||
<span x-text="createLoading ? '생성 중...' : '사용자 생성'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 수정 모달 -->
|
||||
<div x-show="showEditModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">사용자 수정</h3>
|
||||
|
||||
<form @submit.prevent="updateUser()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" x-model="editForm.email" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input type="text" x-model="editForm.full_name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">역할</label>
|
||||
<select x-model="editForm.role"
|
||||
:disabled="editForm.role === 'root'"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50">
|
||||
<option value="user">사용자</option>
|
||||
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
|
||||
<option value="root" x-show="editForm.role === 'root'">시스템 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" x-model="editForm.is_active" class="mr-2">
|
||||
<span class="text-sm font-medium text-gray-700">계정 활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
|
||||
<select x-model="editForm.session_timeout_minutes"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="5">5분 (기본)</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="120">2시간</option>
|
||||
<option value="480">8시간</option>
|
||||
<option value="1440">24시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_books" class="mr-2">
|
||||
<span class="text-sm">서적 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_notes" class="mr-2">
|
||||
<span class="text-sm">노트 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_novels" class="mr-2">
|
||||
<span class="text-sm">소설 관리</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="showEditModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" :disabled="editLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="editLoading"></i>
|
||||
<span x-text="editLoading ? '수정 중...' : '사용자 수정'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div x-show="showDeleteModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">사용자 삭제</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<span x-text="deleteTarget?.full_name || deleteTarget?.email"></span> 사용자를 삭제하시겠습니까?<br>
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="showDeleteModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button @click="deleteUser()" :disabled="deleteLoading"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="deleteLoading"></i>
|
||||
<span x-text="deleteLoading ? '삭제 중...' : '삭제'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
|
||||
<!-- 사용자 관리 스크립트 -->
|
||||
<script>
|
||||
function userManagementApp() {
|
||||
return {
|
||||
users: [],
|
||||
currentUser: {},
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
createLoading: false,
|
||||
editLoading: false,
|
||||
deleteLoading: false,
|
||||
deleteTarget: null,
|
||||
|
||||
createForm: {
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user',
|
||||
can_manage_books: true,
|
||||
can_manage_notes: true,
|
||||
can_manage_novels: true,
|
||||
session_timeout_minutes: 5
|
||||
},
|
||||
|
||||
editForm: {},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 사용자 관리 앱 초기화');
|
||||
await this.loadCurrentUser();
|
||||
await this.loadUsers();
|
||||
},
|
||||
|
||||
async loadCurrentUser() {
|
||||
try {
|
||||
this.currentUser = await api.get('/users/me');
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (!this.currentUser.is_admin) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(this.currentUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 현재 사용자 정보 로드 실패:', error);
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
},
|
||||
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await api.get('/users/');
|
||||
console.log('✅ 사용자 목록 로드 완료:', this.users.length, '명');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 목록 로드 실패:', error);
|
||||
this.showNotification('사용자 목록을 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createUser() {
|
||||
this.createLoading = true;
|
||||
try {
|
||||
await api.post('/users/', this.createForm);
|
||||
|
||||
// 폼 초기화
|
||||
this.createForm = {
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user',
|
||||
can_manage_books: true,
|
||||
can_manage_notes: true,
|
||||
can_manage_novels: true,
|
||||
session_timeout_minutes: 5
|
||||
};
|
||||
|
||||
this.showCreateModal = false;
|
||||
await this.loadUsers();
|
||||
this.showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
|
||||
console.log('✅ 사용자 생성 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 생성 실패:', error);
|
||||
this.showNotification('사용자 생성에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.createLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
editUser(user) {
|
||||
this.editForm = { ...user };
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
async updateUser() {
|
||||
this.editLoading = true;
|
||||
try {
|
||||
await api.put(`/users/${this.editForm.id}`, {
|
||||
full_name: this.editForm.full_name,
|
||||
role: this.editForm.role,
|
||||
is_active: this.editForm.is_active,
|
||||
can_manage_books: this.editForm.can_manage_books,
|
||||
can_manage_notes: this.editForm.can_manage_notes,
|
||||
can_manage_novels: this.editForm.can_manage_novels,
|
||||
session_timeout_minutes: this.editForm.session_timeout_minutes
|
||||
});
|
||||
|
||||
this.showEditModal = false;
|
||||
await this.loadUsers();
|
||||
this.showNotification('사용자 정보가 성공적으로 수정되었습니다.', 'success');
|
||||
console.log('✅ 사용자 수정 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 수정 실패:', error);
|
||||
this.showNotification('사용자 수정에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.editLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteUser(user) {
|
||||
this.deleteTarget = user;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
async deleteUser() {
|
||||
this.deleteLoading = true;
|
||||
try {
|
||||
await api.delete(`/users/${this.deleteTarget.id}`);
|
||||
|
||||
this.showDeleteModal = false;
|
||||
this.deleteTarget = null;
|
||||
await this.loadUsers();
|
||||
this.showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
console.log('✅ 사용자 삭제 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 삭제 실패:', error);
|
||||
this.showNotification('사용자 삭제에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.deleteLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
type,
|
||||
message
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
getRoleText(role) {
|
||||
const roleMap = {
|
||||
'root': '시스템 관리자',
|
||||
'admin': '관리자',
|
||||
'user': '사용자'
|
||||
};
|
||||
return roleMap[role] || '사용자';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,6 +28,24 @@ server {
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
|
||||
# 리다이렉트 방지
|
||||
proxy_redirect off;
|
||||
|
||||
# CORS 헤더 추가
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
|
||||
# OPTIONS 요청 처리
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# 업로드된 문서 파일 서빙
|
||||
|
||||
33
scripts/backup.sh
Executable file
33
scripts/backup.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document Server 데이터베이스 백업 스크립트
|
||||
# 시놀로지 NAS 환경에서 사용
|
||||
|
||||
BACKUP_DIR="/volume1/docker/document-server/backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
CONTAINER_NAME="document-server-db"
|
||||
|
||||
# 백업 디렉토리 생성
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "🔄 데이터베이스 백업 시작: $TIMESTAMP"
|
||||
|
||||
# PostgreSQL 백업
|
||||
docker exec $CONTAINER_NAME pg_dump -U docuser -d document_db > "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
|
||||
|
||||
# 압축
|
||||
gzip "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
|
||||
|
||||
# 7일 이상 된 백업 파일 삭제
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
|
||||
|
||||
echo "✅ 백업 완료: $BACKUP_DIR/document_db_$TIMESTAMP.sql.gz"
|
||||
|
||||
# 업로드 파일 백업 (선택사항)
|
||||
if [ "$1" = "--include-uploads" ]; then
|
||||
echo "🔄 업로드 파일 백업 시작..."
|
||||
tar -czf "$BACKUP_DIR/uploads_$TIMESTAMP.tar.gz" -C /volume1/docker/document-server uploads/
|
||||
echo "✅ 업로드 파일 백업 완료: $BACKUP_DIR/uploads_$TIMESTAMP.tar.gz"
|
||||
fi
|
||||
|
||||
echo "🎉 전체 백업 작업 완료"
|
||||
58
scripts/restore.sh
Executable file
58
scripts/restore.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document Server 데이터베이스 복원 스크립트
|
||||
# 시놀로지 NAS 환경에서 사용
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "사용법: $0 <백업파일명>"
|
||||
echo "예시: $0 document_db_20241201_143000.sql.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
BACKUP_DIR="/volume1/docker/document-server/backups"
|
||||
CONTAINER_NAME="document-server-db"
|
||||
|
||||
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
echo "❌ 백업 파일을 찾을 수 없습니다: $BACKUP_DIR/$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ 주의: 현재 데이터베이스의 모든 데이터가 삭제됩니다!"
|
||||
read -p "계속하시겠습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "복원이 취소되었습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 데이터베이스 복원 시작..."
|
||||
|
||||
# 압축 해제 (필요한 경우)
|
||||
if [[ $BACKUP_FILE == *.gz ]]; then
|
||||
echo "📦 백업 파일 압축 해제 중..."
|
||||
gunzip -c "$BACKUP_DIR/$BACKUP_FILE" > "/tmp/restore_temp.sql"
|
||||
SQL_FILE="/tmp/restore_temp.sql"
|
||||
else
|
||||
SQL_FILE="$BACKUP_DIR/$BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# 기존 데이터베이스 삭제 및 재생성
|
||||
echo "🗑️ 기존 데이터베이스 삭제 중..."
|
||||
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "DROP DATABASE IF EXISTS document_db;"
|
||||
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "CREATE DATABASE document_db;"
|
||||
|
||||
# 백업 복원
|
||||
echo "📥 데이터베이스 복원 중..."
|
||||
docker exec -i $CONTAINER_NAME psql -U docuser -d document_db < "$SQL_FILE"
|
||||
|
||||
# 임시 파일 정리
|
||||
if [ -f "/tmp/restore_temp.sql" ]; then
|
||||
rm "/tmp/restore_temp.sql"
|
||||
fi
|
||||
|
||||
echo "✅ 데이터베이스 복원 완료"
|
||||
echo "🔄 백엔드 서비스 재시작 중..."
|
||||
docker restart document-server-backend
|
||||
|
||||
echo "🎉 복원 작업 완료"
|
||||
BIN
uploads/pdfs/45d099f8-3f72-4354-adc5-a154b7c7014b.pdf
Normal file
BIN
uploads/pdfs/45d099f8-3f72-4354-adc5-a154b7c7014b.pdf
Normal file
Binary file not shown.
BIN
uploads/pdfs/48680d64-c892-4a37-8e25-7b914a2840ea.pdf
Normal file
BIN
uploads/pdfs/48680d64-c892-4a37-8e25-7b914a2840ea.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user