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_note_id IS '도착점 노트 ID';
|
||||||
COMMENT ON COLUMN note_links.target_document_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';
|
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"
|
httpx = "^0.25.0"
|
||||||
aiofiles = "^23.2.0"
|
aiofiles = "^23.2.0"
|
||||||
jinja2 = "^3.1.0"
|
jinja2 = "^3.1.0"
|
||||||
|
beautifulsoup4 = "^4.13.0"
|
||||||
|
pypdf2 = "^3.0.0"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^7.4.0"
|
pytest = "^7.4.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
API 의존성
|
API 의존성
|
||||||
"""
|
"""
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status, Query
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -86,3 +86,54 @@ async def get_optional_current_user(
|
|||||||
return await get_current_user(credentials, db)
|
return await get_current_user(credentials, db)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
return None
|
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"
|
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)})
|
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
|
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(
|
async def create_book(
|
||||||
book_data: CreateBookRequest,
|
book_data: CreateBookRequest,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -58,7 +58,7 @@ async def create_book(
|
|||||||
await db.refresh(new_book)
|
await db.refresh(new_book)
|
||||||
return await _get_book_response(db, 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(
|
async def get_books(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
문서 관리 API 라우터
|
문서 관리 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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, delete, and_, or_, update
|
from sqlalchemy import select, delete, and_, or_, update
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -17,7 +17,7 @@ from ...core.config import settings
|
|||||||
from ...models.user import User
|
from ...models.user import User
|
||||||
from ...models.document import Document, Tag
|
from ...models.document import Document, Tag
|
||||||
from ...models.book import Book
|
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 pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ async def get_document(
|
|||||||
async def get_document_content(
|
async def get_document_content(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
_token: Optional[str] = Query(None),
|
_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)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""문서 HTML 콘텐츠 조회"""
|
"""문서 HTML 콘텐츠 조회"""
|
||||||
@@ -512,7 +512,7 @@ async def get_document_content(
|
|||||||
async def get_document_pdf(
|
async def get_document_pdf(
|
||||||
document_id: str,
|
document_id: str,
|
||||||
_token: Optional[str] = Query(None),
|
_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)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""문서 PDF 파일 조회"""
|
"""문서 PDF 파일 조회"""
|
||||||
@@ -535,6 +535,7 @@ async def get_document_pdf(
|
|||||||
|
|
||||||
# PDF 파일 확인
|
# PDF 파일 확인
|
||||||
if not document.pdf_path:
|
if not document.pdf_path:
|
||||||
|
print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}")
|
||||||
raise HTTPException(status_code=404, detail="PDF file not found for this document")
|
raise HTTPException(status_code=404, detail="PDF file not found for this document")
|
||||||
|
|
||||||
# PDF 파일 경로 처리
|
# PDF 파일 경로 처리
|
||||||
@@ -544,9 +545,21 @@ async def get_document_pdf(
|
|||||||
if document.pdf_path.startswith('/'):
|
if document.pdf_path.startswith('/'):
|
||||||
file_path = document.pdf_path
|
file_path = document.pdf_path
|
||||||
else:
|
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):
|
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")
|
raise HTTPException(status_code=404, detail="PDF file not found on disk")
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ def create_note_document(
|
|||||||
is_published=note_data.is_published,
|
is_published=note_data.is_published,
|
||||||
parent_note_id=note_data.parent_note_id,
|
parent_note_id=note_data.parent_note_id,
|
||||||
sort_order=note_data.sort_order,
|
sort_order=note_data.sort_order,
|
||||||
|
notebook_id=note_data.notebook_id,
|
||||||
created_by=current_user.email,
|
created_by=current_user.email,
|
||||||
word_count=word_count,
|
word_count=word_count,
|
||||||
reading_time=reading_time
|
reading_time=reading_time
|
||||||
|
|||||||
@@ -390,12 +390,15 @@ async def search_highlight_notes(
|
|||||||
# 하이라이트가 있는 노트만
|
# 하이라이트가 있는 노트만
|
||||||
query_obj = query_obj.where(Note.highlight_id.isnot(None))
|
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:
|
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}%"))
|
query_obj = query_obj.where(Note.content.ilike(f"%{query}%"))
|
||||||
@@ -560,7 +563,7 @@ async def search_document_content(
|
|||||||
if doc.html_path.startswith('/'):
|
if doc.html_path.startswith('/'):
|
||||||
html_file_path = doc.html_path
|
html_file_path = doc.html_path
|
||||||
else:
|
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):
|
if os.path.exists(html_file_path):
|
||||||
with open(html_file_path, 'r', encoding='utf-8') as f:
|
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('/'):
|
if doc.pdf_path.startswith('/'):
|
||||||
pdf_file_path = doc.pdf_path
|
pdf_file_path = doc.pdf_path
|
||||||
else:
|
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):
|
if os.path.exists(pdf_file_path):
|
||||||
with open(pdf_file_path, 'rb') as f:
|
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 fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update, delete
|
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.database import get_db
|
||||||
|
from ...core.security import get_password_hash, verify_password
|
||||||
from ...models.user import User
|
from ...models.user import User
|
||||||
from ...schemas.auth import UserInfo
|
|
||||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile", response_model=UserInfo)
|
class UserResponse(BaseModel):
|
||||||
async def get_profile(
|
"""사용자 응답"""
|
||||||
|
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)
|
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)
|
@router.put("/me", response_model=UserResponse)
|
||||||
async def update_profile(
|
async def update_current_user_profile(
|
||||||
profile_data: UpdateProfileRequest,
|
profile_data: UpdateProfileRequest,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""프로필 업데이트"""
|
"""현재 사용자 프로필 업데이트"""
|
||||||
update_data = {}
|
update_fields = profile_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if profile_data.full_name is not None:
|
for field, value in update_fields.items():
|
||||||
update_data["full_name"] = profile_data.full_name
|
setattr(current_user, field, value)
|
||||||
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
|
|
||||||
|
|
||||||
if update_data:
|
current_user.updated_at = datetime.utcnow()
|
||||||
await db.execute(
|
await db.commit()
|
||||||
update(User)
|
await db.refresh(current_user)
|
||||||
.where(User.id == current_user.id)
|
|
||||||
.values(**update_data)
|
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(
|
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)
|
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()
|
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)
|
@router.post("/", response_model=UserResponse)
|
||||||
async def get_user(
|
async def create_user(
|
||||||
user_id: str,
|
user_data: CreateUserRequest,
|
||||||
admin_user: User = Depends(get_current_admin_user),
|
current_user: User = Depends(get_current_admin_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
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))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -96,18 +280,34 @@ async def get_user(
|
|||||||
detail="User not found"
|
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(
|
async def update_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
user_data: UpdateUserRequest,
|
user_data: UpdateUserRequest,
|
||||||
admin_user: User = Depends(get_current_admin_user),
|
current_user: User = Depends(get_current_admin_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""사용자 정보 업데이트 (관리자 전용)"""
|
"""사용자 정보 업데이트 (관리자 전용)"""
|
||||||
# 사용자 존재 확인
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -117,42 +317,55 @@ async def update_user(
|
|||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 자기 자신의 관리자 권한은 제거할 수 없음
|
# 권한 확인 (root만 admin/root 계정 수정 가능)
|
||||||
if user.id == admin_user.id and user_data.is_admin is False:
|
if user.role in ["admin", "root"] and current_user.role != "root":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Cannot remove admin privileges from yourself"
|
detail="Only root users can modify admin accounts"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 업데이트할 데이터 준비
|
# 업데이트할 필드들 적용
|
||||||
update_data = {}
|
update_fields = user_data.model_dump(exclude_unset=True)
|
||||||
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
|
|
||||||
|
|
||||||
if update_data:
|
for field, value in update_fields.items():
|
||||||
await db.execute(
|
if field == "role":
|
||||||
update(User)
|
# 역할 변경 시 is_admin도 함께 업데이트
|
||||||
.where(User.id == user_id)
|
setattr(user, field, value)
|
||||||
.values(**update_data)
|
user.is_admin = value in ["admin", "root"]
|
||||||
)
|
else:
|
||||||
await db.commit()
|
setattr(user, field, value)
|
||||||
await db.refresh(user)
|
|
||||||
|
|
||||||
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}")
|
@router.delete("/{user_id}")
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
admin_user: User = Depends(get_current_admin_user),
|
current_user: User = Depends(get_current_admin_user),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""사용자 삭제 (관리자 전용)"""
|
"""사용자 삭제 (관리자 전용)"""
|
||||||
# 사용자 존재 확인
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -162,15 +375,28 @@ async def delete_user(
|
|||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 자기 자신은 삭제할 수 없음
|
# 자기 자신 삭제 방지
|
||||||
if user.id == admin_user.id:
|
if user.id == current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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.execute(delete(User).where(User.id == user_id))
|
||||||
await db.commit()
|
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)
|
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()
|
to_encode = data.copy()
|
||||||
|
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + 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:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import uvicorn
|
|||||||
|
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
from .core.database import init_db
|
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 import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
|
||||||
from .api.routes.notes import router as note_documents_router
|
|
||||||
from .api.routes import note_documents, note_links
|
from .api.routes import note_documents, note_links
|
||||||
|
|
||||||
|
|
||||||
@@ -44,19 +43,19 @@ app.add_middleware(
|
|||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|
||||||
# API 라우터 등록
|
# API 라우터 등록
|
||||||
|
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
|
||||||
app.include_router(users.router, prefix="/api/users", tags=["사용자"])
|
app.include_router(users.router, prefix="/api/users", tags=["사용자"])
|
||||||
app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
|
app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
|
||||||
app.include_router(highlights.router, prefix="/api/highlights", 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(books.router, prefix="/api/books", tags=["서적"])
|
||||||
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
|
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
|
||||||
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
|
||||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||||
app.include_router(document_links.router, prefix="/api/documents", 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(note_links.router, prefix="/api", tags=["노트 링크"])
|
||||||
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
|
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
|
||||||
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
|
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel):
|
|||||||
tags: List[str] = Field(default=[])
|
tags: List[str] = Field(default=[])
|
||||||
is_published: bool = Field(default=False)
|
is_published: bool = Field(default=False)
|
||||||
parent_note_id: Optional[str] = None
|
parent_note_id: Optional[str] = None
|
||||||
|
notebook_id: Optional[str] = None
|
||||||
sort_order: int = Field(default=0)
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
class NoteDocumentCreate(NoteDocumentBase):
|
class NoteDocumentCreate(NoteDocumentBase):
|
||||||
@@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel):
|
|||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
is_published: Optional[bool] = None
|
is_published: Optional[bool] = None
|
||||||
parent_note_id: Optional[str] = None
|
parent_note_id: Optional[str] = None
|
||||||
|
notebook_id: Optional[str] = None
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
class NoteDocumentResponse(NoteDocumentBase):
|
class NoteDocumentResponse(NoteDocumentBase):
|
||||||
@@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase):
|
|||||||
'tags': obj.tags or [],
|
'tags': obj.tags or [],
|
||||||
'is_published': obj.is_published,
|
'is_published': obj.is_published,
|
||||||
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
|
'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,
|
'sort_order': obj.sort_order,
|
||||||
'markdown_content': obj.markdown_content,
|
'markdown_content': obj.markdown_content,
|
||||||
'created_at': obj.created_at,
|
'created_at': obj.created_at,
|
||||||
|
|||||||
@@ -55,3 +55,4 @@ class NoteLink(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
@@ -21,6 +21,17 @@ class User(Base):
|
|||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
is_admin = Column(Boolean, default=False)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|||||||
@@ -33,10 +33,16 @@ class UserInfo(BaseModel):
|
|||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
role: str
|
||||||
|
can_manage_books: bool
|
||||||
|
can_manage_notes: bool
|
||||||
|
can_manage_novels: bool
|
||||||
|
session_timeout_minutes: int
|
||||||
theme: str
|
theme: str
|
||||||
language: str
|
language: str
|
||||||
timezone: str
|
timezone: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -89,3 +89,4 @@ jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값
|
|||||||
|
|
||||||
# 확장 모듈 설정
|
# 확장 모듈 설정
|
||||||
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈
|
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈
|
||||||
|
|
||||||
|
|||||||
@@ -152,3 +152,4 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: /volume2/document-storage
|
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="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center h-16">
|
<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>
|
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||||
</div>
|
</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">
|
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||||
<a href="index.html" class="nav-link" id="doc-nav-link">
|
<button class="nav-link-modern" id="doc-nav-link">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open text-blue-600"></i>
|
||||||
<span>문서 관리</span>
|
<span>문서 관리</span>
|
||||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||||
</a>
|
</button>
|
||||||
<div x-show="open" x-transition class="nav-dropdown">
|
<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">
|
||||||
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
|
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
|
||||||
</a>
|
<div class="flex items-center space-x-3">
|
||||||
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
|
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
|
<i class="fas fa-th-large text-blue-600"></i>
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 통합 검색 -->
|
<!-- 통합 검색 -->
|
||||||
<a href="search.html" class="nav-link" id="search-nav-link">
|
<a href="search.html" class="nav-link-modern" id="search-nav-link">
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search text-green-600"></i>
|
||||||
<span>통합 검색</span>
|
<span>통합 검색</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- 소설 관리 시스템 -->
|
<!-- 소설 관리 -->
|
||||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
<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">
|
<button class="nav-link-modern" id="novel-nav-link">
|
||||||
<i class="fas fa-feather-alt"></i>
|
<i class="fas fa-feather-alt text-purple-600"></i>
|
||||||
<span>소설 관리</span>
|
<span>소설 관리</span>
|
||||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||||
</a>
|
</button>
|
||||||
<div x-show="open" x-transition class="nav-dropdown">
|
<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">
|
||||||
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
|
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
|
||||||
</a>
|
<div class="flex items-center space-x-3">
|
||||||
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
|
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
|
<i class="fas fa-sitemap text-purple-600"></i>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- 노트 관리 시스템 -->
|
<!-- 노트 관리 -->
|
||||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||||
<a href="notes.html" class="nav-link" id="notes-nav-link">
|
<button class="nav-link-modern" id="notes-nav-link">
|
||||||
<i class="fas fa-sticky-note"></i>
|
<i class="fas fa-sticky-note text-yellow-600"></i>
|
||||||
<span>노트 관리</span>
|
<span>노트 관리</span>
|
||||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
|
||||||
</a>
|
</button>
|
||||||
<div x-show="open" x-transition class="nav-dropdown">
|
<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">
|
||||||
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
|
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
|
||||||
</a>
|
<div class="flex flex-col items-center text-center space-y-2">
|
||||||
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
|
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
|
<i class="fas fa-book text-blue-600"></i>
|
||||||
</a>
|
</div>
|
||||||
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
|
<div>
|
||||||
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
|
<div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
|
||||||
</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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">
|
<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="flex items-center space-x-3" id="user-menu">
|
||||||
<!-- 로그인된 사용자 -->
|
<!-- 로그인된 사용자 드롭다운 -->
|
||||||
<div class="hidden" id="logged-in-menu">
|
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
|
||||||
<span class="text-sm text-gray-600" id="user-name">User</span>
|
<button @click="open = !open" class="user-menu-btn">
|
||||||
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
|
<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-sign-out-alt"></i> 로그아웃
|
<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>
|
</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>
|
||||||
|
|
||||||
<!-- 로그인 버튼 -->
|
<!-- 로그인 버튼 -->
|
||||||
<div class="hidden" id="login-button">
|
<div class="" id="login-button">
|
||||||
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
|
<button id="login-btn" class="login-btn-modern">
|
||||||
<i class="fas fa-sign-in-alt"></i> 로그인
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
|
<span>로그인</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,35 +242,128 @@
|
|||||||
|
|
||||||
<!-- 헤더 관련 스타일 -->
|
<!-- 헤더 관련 스타일 -->
|
||||||
<style>
|
<style>
|
||||||
/* 네비게이션 링크 스타일 */
|
/* 모던 네비게이션 링크 스타일 */
|
||||||
.nav-link {
|
.nav-link-modern {
|
||||||
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
|
@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 {
|
.nav-link-modern:hover {
|
||||||
@apply text-blue-600 bg-blue-50 font-medium;
|
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
|
||||||
|
border-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link-modern.active {
|
||||||
@apply bg-gray-50;
|
@apply text-blue-700 bg-blue-50 border-blue-200;
|
||||||
|
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 드롭다운 메뉴 스타일 */
|
/* 와이드 드롭다운 메뉴 스타일 */
|
||||||
.nav-dropdown {
|
.nav-dropdown-wide {
|
||||||
@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;
|
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 {
|
.nav-dropdown-card {
|
||||||
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
|
@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 {
|
.nav-dropdown-card:hover {
|
||||||
@apply text-blue-600 bg-blue-50 font-medium;
|
@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 {
|
.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 = () => {
|
window.handleLogin = () => {
|
||||||
console.log('🔐 handleLogin 호출됨');
|
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
|
||||||
|
const currentUrl = encodeURIComponent(window.location.href);
|
||||||
// Alpine.js 컨텍스트에서 함수 찾기
|
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||||
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'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.handleLogout = () => {
|
window.handleLogout = () => {
|
||||||
@@ -259,22 +450,73 @@
|
|||||||
|
|
||||||
// 사용자 상태 업데이트 함수
|
// 사용자 상태 업데이트 함수
|
||||||
window.updateUserMenu = (user) => {
|
window.updateUserMenu = (user) => {
|
||||||
|
console.log('🔄 updateUserMenu 호출됨:', user);
|
||||||
|
|
||||||
const loggedInMenu = document.getElementById('logged-in-menu');
|
const loggedInMenu = document.getElementById('logged-in-menu');
|
||||||
const loginButton = document.getElementById('login-button');
|
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 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 (user) {
|
||||||
// 로그인된 상태
|
// 로그인된 상태
|
||||||
if (loggedInMenu) loggedInMenu.classList.remove('hidden');
|
console.log('✅ 사용자 로그인 상태 - UI 업데이트 시작');
|
||||||
if (loginButton) loginButton.classList.add('hidden');
|
if (loggedInMenu) {
|
||||||
if (userName) userName.textContent = user.username || user.full_name || user.email || 'User';
|
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 {
|
} else {
|
||||||
// 로그아웃된 상태
|
// 로그아웃된 상태
|
||||||
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
if (loggedInMenu) loggedInMenu.classList.add('hidden');
|
||||||
if (loginButton) loginButton.classList.remove('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) => {
|
window.handleLanguageChange = (lang) => {
|
||||||
@@ -335,9 +577,27 @@
|
|||||||
// 향후 다국어 지원 시 구현
|
// 향후 다국어 지원 시 구현
|
||||||
};
|
};
|
||||||
|
|
||||||
// 헤더 로드 완료 후 언어 설정 적용
|
// 헤더 로드 완료 후 이벤트 바인딩
|
||||||
document.addEventListener('headerLoaded', () => {
|
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';
|
const savedLang = localStorage.getItem('preferred_language') || 'ko';
|
||||||
console.log('💾 저장된 언어 설정 적용:', savedLang);
|
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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/main.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>
|
<script src="static/js/header-loader.js"></script>
|
||||||
</head>
|
</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"
|
class="absolute tree-diagram-node"
|
||||||
:style="getNodePosition(node)"
|
:style="getNodePosition(node)"
|
||||||
:data-node-id="node.id"
|
:data-node-id="node.id"
|
||||||
@mousedown="startDragNode($event, node)"
|
@click="selectNode(node)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
|
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,
|
'tree-node-canonical': node.is_canonical,
|
||||||
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
|
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
|
||||||
}"
|
}"
|
||||||
@click="selectNode(node)"
|
|
||||||
@dblclick="editNodeInline(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>
|
<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">
|
<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 라이브러리 -->
|
<!-- PDF.js 라이브러리 -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -251,6 +254,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="flex items-center space-x-2 ml-auto">
|
||||||
<span class="text-sm font-medium text-gray-700">정렬:</span>
|
<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">
|
<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="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
|
||||||
<!-- 결과 헤더 -->
|
<!-- 결과 헤더 -->
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
@@ -323,6 +345,29 @@
|
|||||||
<span class="result-type-badge"
|
<span class="result-type-badge"
|
||||||
:class="`badge-${result.type}`"
|
:class="`badge-${result.type}`"
|
||||||
x-text="getTypeLabel(result.type)"></span>
|
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>
|
<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">
|
<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>
|
<i class="fas fa-star text-yellow-500 mr-1"></i>
|
||||||
@@ -483,7 +528,8 @@
|
|||||||
<!-- PDF 뷰어 컨테이너 -->
|
<!-- PDF 뷰어 컨테이너 -->
|
||||||
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
|
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
|
||||||
<!-- PDF iframe 뷰어 -->
|
<!-- PDF iframe 뷰어 -->
|
||||||
<iframe x-show="!pdfError && !pdfLoading"
|
<iframe id="pdf-preview-iframe"
|
||||||
|
x-show="!pdfError && !pdfLoading"
|
||||||
class="w-full h-full border-0"
|
class="w-full h-full border-0"
|
||||||
:src="pdfSrc"
|
:src="pdfSrc"
|
||||||
@load="pdfLoaded = true"
|
@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 {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
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() {
|
async getCurrentUser() {
|
||||||
return await this.get('/auth/me');
|
return await this.get('/users/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken) {
|
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(() => {
|
setTimeout(() => {
|
||||||
window.headerLoader.updateActiveStates();
|
window.headerLoader.updateActiveStates();
|
||||||
|
|
||||||
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
|
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
|
||||||
if (typeof window.updateUserMenu === 'function') {
|
if (typeof window.updateUserMenu === 'undefined') {
|
||||||
window.updateUserMenu(null);
|
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);
|
}, 100);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ window.documentApp = () => ({
|
|||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.currentUser = null;
|
this.currentUser = null;
|
||||||
localStorage.removeItem('access_token');
|
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 상태 동기화
|
this.syncUIState(); // UI 상태 동기화
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ window.memoTreeApp = function() {
|
|||||||
treePanX: 0,
|
treePanX: 0,
|
||||||
treePanY: 0,
|
treePanY: 0,
|
||||||
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
||||||
isDragging: false,
|
|
||||||
dragNode: null,
|
|
||||||
dragOffset: { x: 0, y: 0 },
|
|
||||||
|
|
||||||
// 로그인 관련 함수들
|
// 로그인 관련 함수들
|
||||||
openLoginModal() {
|
openLoginModal() {
|
||||||
@@ -290,7 +287,15 @@ window.memoTreeApp = function() {
|
|||||||
|
|
||||||
const node = await window.api.createMemoNode(nodeData);
|
const node = await window.api.createMemoNode(nodeData);
|
||||||
this.treeNodes.push(node);
|
this.treeNodes.push(node);
|
||||||
this.selectNode(node);
|
|
||||||
|
// 노드 위치 재계산 (새 노드 추가 후)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.calculateNodePositions();
|
||||||
|
// 위치 계산 완료 후 새 노드 선택
|
||||||
|
setTimeout(() => {
|
||||||
|
this.selectNode(node);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
console.log('✅ 루트 노드 생성 완료');
|
console.log('✅ 루트 노드 생성 완료');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -301,6 +306,11 @@ window.memoTreeApp = function() {
|
|||||||
|
|
||||||
// 노드 선택
|
// 노드 선택
|
||||||
selectNode(node) {
|
selectNode(node) {
|
||||||
|
// 현재 팬 값 저장 (위치 변경 방지)
|
||||||
|
const currentPanX = this.treePanX;
|
||||||
|
const currentPanY = this.treePanY;
|
||||||
|
const currentZoom = this.treeZoom;
|
||||||
|
|
||||||
// 이전 노드 저장
|
// 이전 노드 저장
|
||||||
if (this.selectedNode && this.isEditorDirty) {
|
if (this.selectedNode && this.isEditorDirty) {
|
||||||
this.saveNode();
|
this.saveNode();
|
||||||
@@ -323,6 +333,11 @@ window.memoTreeApp = function() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 팬 값 복원 (위치 변경 방지)
|
||||||
|
this.treePanX = currentPanX;
|
||||||
|
this.treePanY = currentPanY;
|
||||||
|
this.treeZoom = currentZoom;
|
||||||
|
|
||||||
console.log('📝 노드 선택:', node.title);
|
console.log('📝 노드 선택:', node.title);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -552,8 +567,14 @@ window.memoTreeApp = function() {
|
|||||||
// 부모 노드 펼치기
|
// 부모 노드 펼치기
|
||||||
this.expandedNodes.add(parentNode.id);
|
this.expandedNodes.add(parentNode.id);
|
||||||
|
|
||||||
// 새 노드 선택
|
// 노드 위치 재계산 (새 노드 추가 후)
|
||||||
this.selectNode(node);
|
this.$nextTick(() => {
|
||||||
|
this.calculateNodePositions();
|
||||||
|
// 위치 계산 완료 후 새 노드 선택
|
||||||
|
setTimeout(() => {
|
||||||
|
this.selectNode(node);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
console.log('✅ 자식 노드 생성 완료');
|
console.log('✅ 자식 노드 생성 완료');
|
||||||
} catch (error) {
|
} 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) {
|
async moveNodeToParent(nodeId, newParentId) {
|
||||||
@@ -783,15 +690,12 @@ window.memoTreeApp = function() {
|
|||||||
|
|
||||||
// 노드 위치 계산 및 반환
|
// 노드 위치 계산 및 반환
|
||||||
getNodePosition(node) {
|
getNodePosition(node) {
|
||||||
if (!this.nodePositions.has(node.id)) {
|
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
|
||||||
this.calculateNodePositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
||||||
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 트리 노드 위치 자동 계산
|
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
|
||||||
calculateNodePositions() {
|
calculateNodePositions() {
|
||||||
const canvas = document.getElementById('tree-canvas');
|
const canvas = document.getElementById('tree-canvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -802,11 +706,11 @@ window.memoTreeApp = function() {
|
|||||||
// 노드 크기 설정
|
// 노드 크기 설정
|
||||||
const nodeWidth = 200;
|
const nodeWidth = 200;
|
||||||
const nodeHeight = 80;
|
const nodeHeight = 80;
|
||||||
const levelHeight = 150; // 레벨 간 간격
|
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
|
||||||
const nodeSpacing = 50; // 노드 간 간격
|
const nodeSpacing = 100; // 노드 간 세로 간격
|
||||||
const margin = 100; // 여백
|
const margin = 100; // 여백
|
||||||
|
|
||||||
// 레벨별 노드 그룹화
|
// 레벨별 노드 그룹화 (가로 방향)
|
||||||
const levels = new Map();
|
const levels = new Map();
|
||||||
|
|
||||||
// 루트 노드들 찾기
|
// 루트 노드들 찾기
|
||||||
@@ -814,7 +718,7 @@ window.memoTreeApp = function() {
|
|||||||
|
|
||||||
if (rootNodes.length === 0) return;
|
if (rootNodes.length === 0) return;
|
||||||
|
|
||||||
// BFS로 레벨별 노드 배치
|
// BFS로 레벨별 노드 배치 (가로 방향)
|
||||||
const queue = [];
|
const queue = [];
|
||||||
rootNodes.forEach(node => {
|
rootNodes.forEach(node => {
|
||||||
queue.push({ node, level: 0 });
|
queue.push({ node, level: 0 });
|
||||||
@@ -835,25 +739,25 @@ window.memoTreeApp = function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트리 전체 크기 계산
|
// 트리 전체 크기 계산 (가로 방향)
|
||||||
const maxLevel = Math.max(...levels.keys());
|
const maxLevel = Math.max(...levels.keys());
|
||||||
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
||||||
|
|
||||||
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
|
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
|
||||||
const treeHeight = (maxLevel + 1) * levelHeight;
|
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
|
||||||
|
|
||||||
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
||||||
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
||||||
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
||||||
|
|
||||||
// 각 레벨의 노드들 위치 계산
|
// 각 레벨의 노드들 위치 계산 (가로 방향)
|
||||||
levels.forEach((nodes, level) => {
|
levels.forEach((nodes, level) => {
|
||||||
const y = offsetY + level * levelHeight;
|
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
|
||||||
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
|
||||||
const startX = offsetX + (treeWidth - levelWidth) / 2;
|
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
nodes.forEach((node, index) => {
|
||||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
const y = startY + index * (nodeHeight + nodeSpacing);
|
||||||
this.nodePositions.set(node.id, { x, y });
|
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 line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
|
||||||
// 부모 노드 하단 중앙에서 시작
|
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
|
||||||
const startX = parentPos.x + 100; // 노드 중앙
|
const startX = parentPos.x + 200; // 노드 오른쪽 끝
|
||||||
const startY = parentPos.y + 80; // 노드 하단
|
const startY = parentPos.y + 40; // 노드 세로 중앙
|
||||||
|
|
||||||
// 자식 노드 상단 중앙으로 연결
|
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
|
||||||
const endX = childPos.x + 100; // 노드 중앙
|
const endX = childPos.x; // 노드 왼쪽 끝
|
||||||
const endY = childPos.y; // 노드 상단
|
const endY = childPos.y + 40; // 노드 세로 중앙
|
||||||
|
|
||||||
// 곡선 경로 생성 (베지어 곡선)
|
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
|
||||||
const midY = startY + (endY - startY) / 2;
|
const midX = startX + (endX - startX) / 2;
|
||||||
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
|
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
|
||||||
|
|
||||||
line.setAttribute('d', path);
|
line.setAttribute('d', path);
|
||||||
line.setAttribute('stroke', '#9CA3AF');
|
line.setAttribute('stroke', '#9CA3AF');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ window.searchApp = function() {
|
|||||||
|
|
||||||
// 필터링
|
// 필터링
|
||||||
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
||||||
|
fileTypeFilter: '', // '', 'PDF', 'HTML'
|
||||||
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
||||||
|
|
||||||
// 검색 디바운스
|
// 검색 디바운스
|
||||||
@@ -157,9 +158,43 @@ window.searchApp = function() {
|
|||||||
applyFilters() {
|
applyFilters() {
|
||||||
let results = [...this.searchResults];
|
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) {
|
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;
|
this.filteredResults = results;
|
||||||
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
|
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
|
||||||
},
|
},
|
||||||
|
|
||||||
// URL 업데이트
|
// URL 업데이트
|
||||||
@@ -339,22 +374,23 @@ window.searchApp = function() {
|
|||||||
this.pdfLoaded = false;
|
this.pdfLoaded = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// PDF 파일 존재 여부 먼저 확인
|
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
|
||||||
const response = await fetch(`/api/documents/${documentId}/pdf`, {
|
const token = localStorage.getItem('access_token');
|
||||||
method: 'HEAD',
|
console.log('🔍 토큰 디버깅:', {
|
||||||
headers: {
|
token: token,
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
tokenType: typeof token,
|
||||||
}
|
tokenLength: token ? token.length : 0,
|
||||||
|
isNull: token === null,
|
||||||
|
isStringNull: token === 'null',
|
||||||
|
localStorage: Object.keys(localStorage)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!token || token === 'null' || token === null) {
|
||||||
// PDF 파일이 존재하면 src 설정
|
console.error('❌ 토큰 문제:', token);
|
||||||
const token = localStorage.getItem('token');
|
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||||
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
|
||||||
console.log('PDF 미리보기 준비 완료:', this.pdfSrc);
|
|
||||||
} else {
|
|
||||||
throw new Error(`PDF 파일을 찾을 수 없습니다 (${response.status})`);
|
|
||||||
}
|
}
|
||||||
|
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||||
|
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF 미리보기 로드 실패:', error);
|
console.error('PDF 미리보기 로드 실패:', error);
|
||||||
this.pdfError = true;
|
this.pdfError = true;
|
||||||
@@ -370,6 +406,50 @@ window.searchApp = function() {
|
|||||||
this.pdfLoading = false;
|
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 미리보기 로드
|
// HTML 미리보기 로드
|
||||||
async loadHtmlPreview(documentId) {
|
async loadHtmlPreview(documentId) {
|
||||||
this.htmlLoading = true;
|
this.htmlLoading = true;
|
||||||
@@ -385,7 +465,12 @@ window.searchApp = function() {
|
|||||||
const iframe = document.getElementById('htmlPreviewFrame');
|
const iframe = document.getElementById('htmlPreviewFrame');
|
||||||
if (iframe) {
|
if (iframe) {
|
||||||
// iframe src를 직접 설정 (인증 헤더 포함)
|
// 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.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
// iframe 로드 완료 후 검색어 하이라이트
|
// 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_buffering on;
|
||||||
proxy_buffer_size 4k;
|
proxy_buffer_size 4k;
|
||||||
proxy_buffers 8 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