Fix: 업로드 및 API 연결 문제 해결

- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정
- Nginx 프록시 설정에서 경로 중복 문제 해결
- 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정)
- 노트북 연결 기능 수정 (notebook_id 필드 추가)
- 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거)
- 헤더 UI 개선 및 고정 위치 설정
- 백업/복원 스크립트 추가
- PDF 미리보기 토큰 인증 지원
This commit is contained in:
Hyungi Ahn
2025-09-03 15:58:10 +09:00
parent d4b10b16b1
commit 6e01dbdeb3
47 changed files with 3672 additions and 398 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

View File

@@ -72,3 +72,4 @@ COMMENT ON COLUMN note_links.source_document_id IS '출발점 문서 ID (문서
COMMENT ON COLUMN note_links.target_note_id IS '도착점 노트 ID';
COMMENT ON COLUMN note_links.target_document_id IS '도착점 문서 ID';
COMMENT ON COLUMN note_links.link_type IS '링크 타입: note, document, text_fragment';

View File

@@ -23,6 +23,8 @@ python-dotenv = "^1.0.0"
httpx = "^0.25.0"
aiofiles = "^23.2.0"
jinja2 = "^3.1.0"
beautifulsoup4 = "^4.13.0"
pypdf2 = "^3.0.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"

View File

@@ -1,7 +1,7 @@
"""
API 의존성
"""
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -86,3 +86,54 @@ async def get_optional_current_user(
return await get_current_user(credentials, db)
except HTTPException:
return None
async def get_current_user_with_token_param(
_token: Optional[str] = Query(None),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""URL 파라미터 또는 헤더에서 토큰을 가져와서 사용자 인증"""
token = None
# URL 파라미터에서 토큰 확인
if _token:
token = _token
# Authorization 헤더에서 토큰 확인
elif credentials:
token = credentials.credentials
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No authentication token provided"
)
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(token)
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
return user
except Exception as e:
print(f"🚫 토큰 인증 실패: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)

View File

@@ -46,8 +46,11 @@ async def login(
detail="Inactive user"
)
# 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)})
# 사용자별 세션 타임아웃을 적용한 토큰 생성
access_token = create_access_token(
data={"sub": str(user.id)},
timeout_minutes=user.session_timeout_minutes
)
refresh_token = create_refresh_token(data={"sub": str(user.id)})
# 마지막 로그인 시간 업데이트

View File

@@ -33,7 +33,7 @@ async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
document_count=document_count
)
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
@router.post("", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
async def create_book(
book_data: CreateBookRequest,
current_user: User = Depends(get_current_active_user),
@@ -58,7 +58,7 @@ async def create_book(
await db.refresh(new_book)
return await _get_book_response(db, new_book)
@router.get("/", response_model=List[BookResponse])
@router.get("", response_model=List[BookResponse])
async def get_books(
skip: int = 0,
limit: int = 50,

View File

@@ -1,7 +1,7 @@
"""
문서 관리 API 라우터
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, or_, update
from sqlalchemy.orm import selectinload
@@ -17,7 +17,7 @@ from ...core.config import settings
from ...models.user import User
from ...models.document import Document, Tag
from ...models.book import Book
from ..dependencies import get_current_active_user, get_current_admin_user
from ..dependencies import get_current_active_user, get_current_admin_user, get_current_user_with_token_param
from pydantic import BaseModel
from datetime import datetime
@@ -469,7 +469,7 @@ async def get_document(
async def get_document_content(
document_id: str,
_token: Optional[str] = Query(None),
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(get_current_user_with_token_param),
db: AsyncSession = Depends(get_db)
):
"""문서 HTML 콘텐츠 조회"""
@@ -512,7 +512,7 @@ async def get_document_content(
async def get_document_pdf(
document_id: str,
_token: Optional[str] = Query(None),
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(get_current_user_with_token_param),
db: AsyncSession = Depends(get_db)
):
"""문서 PDF 파일 조회"""
@@ -535,6 +535,7 @@ async def get_document_pdf(
# PDF 파일 확인
if not document.pdf_path:
print(f"🚫 PDF 경로가 데이터베이스에 없음: {document.title}")
raise HTTPException(status_code=404, detail="PDF file not found for this document")
# PDF 파일 경로 처리
@@ -544,9 +545,21 @@ async def get_document_pdf(
if document.pdf_path.startswith('/'):
file_path = document.pdf_path
else:
file_path = os.path.join("/app/data/documents", document.pdf_path)
# PDF 파일은 /app/uploads에 저장됨
file_path = os.path.join("/app", document.pdf_path)
print(f"🔍 PDF 파일 경로 확인: {file_path}")
print(f"📁 데이터베이스 PDF 경로: {document.pdf_path}")
if not os.path.exists(file_path):
print(f"🚫 PDF 파일이 디스크에 없음: {file_path}")
# 디렉토리 내용 확인
dir_path = os.path.dirname(file_path)
if os.path.exists(dir_path):
files = os.listdir(dir_path)
print(f"📂 디렉토리 내용: {files[:10]}")
else:
print(f"📂 디렉토리도 없음: {dir_path}")
raise HTTPException(status_code=404, detail="PDF file not found on disk")
return FileResponse(

View File

@@ -178,6 +178,7 @@ def create_note_document(
is_published=note_data.is_published,
parent_note_id=note_data.parent_note_id,
sort_order=note_data.sort_order,
notebook_id=note_data.notebook_id,
created_by=current_user.email,
word_count=word_count,
reading_time=reading_time

View File

@@ -390,12 +390,15 @@ async def search_highlight_notes(
# 하이라이트가 있는 노트만
query_obj = query_obj.where(Note.highlight_id.isnot(None))
# Highlight와 조인 (권한 및 문서 필터링을 위해)
query_obj = query_obj.join(Highlight)
# 권한 필터링 - 사용자의 노트만
query_obj = query_obj.where(Note.created_by == current_user.id)
query_obj = query_obj.where(Highlight.user_id == current_user.id)
# 특정 문서 필터
if document_id:
query_obj = query_obj.join(Highlight).where(Highlight.document_id == document_id)
query_obj = query_obj.where(Highlight.document_id == document_id)
# 메모 내용에서 검색
query_obj = query_obj.where(Note.content.ilike(f"%{query}%"))
@@ -560,7 +563,7 @@ async def search_document_content(
if doc.html_path.startswith('/'):
html_file_path = doc.html_path
else:
html_file_path = os.path.join("/app/data/documents", doc.html_path)
html_file_path = os.path.join("/app", doc.html_path)
if os.path.exists(html_file_path):
with open(html_file_path, 'r', encoding='utf-8') as f:
@@ -590,7 +593,7 @@ async def search_document_content(
if doc.pdf_path.startswith('/'):
pdf_file_path = doc.pdf_path
else:
pdf_file_path = os.path.join("/app/data/documents", doc.pdf_path)
pdf_file_path = os.path.join("/app", doc.pdf_path)
if os.path.exists(pdf_file_path):
with open(pdf_file_path, 'rb') as f:

View 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
}
}

View File

@@ -1,92 +1,276 @@
"""
사용자 관리 API 라우터
사용자 관리 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from typing import List
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
from ...core.database import get_db
from ...core.security import get_password_hash, verify_password
from ...models.user import User
from ...schemas.auth import UserInfo
from ..dependencies import get_current_active_user, get_current_admin_user
from pydantic import BaseModel
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: str = None
theme: str = None
language: str = None
timezone: str = None
class UpdateUserRequest(BaseModel):
"""사용자 정보 업데이트 요청 (관리자용)"""
full_name: str = None
is_active: bool = None
is_admin: bool = None
router = APIRouter()
@router.get("/profile", response_model=UserInfo)
async def get_profile(
class UserResponse(BaseModel):
"""사용자 응답"""
id: str
email: str
full_name: Optional[str]
is_active: bool
is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str
language: str
timezone: str
created_at: datetime
updated_at: Optional[datetime]
last_login: Optional[datetime]
class Config:
from_attributes = True
class CreateUserRequest(BaseModel):
"""사용자 생성 요청"""
email: EmailStr
password: str
full_name: Optional[str] = None
role: str = "user"
can_manage_books: bool = True
can_manage_notes: bool = True
can_manage_novels: bool = True
session_timeout_minutes: int = 5
class UpdateUserRequest(BaseModel):
"""사용자 업데이트 요청"""
full_name: Optional[str] = None
is_active: Optional[bool] = None
role: Optional[str] = None
can_manage_books: Optional[bool] = None
can_manage_notes: Optional[bool] = None
can_manage_novels: Optional[bool] = None
session_timeout_minutes: Optional[int] = None
class UpdateProfileRequest(BaseModel):
"""프로필 업데이트 요청"""
full_name: Optional[str] = None
theme: Optional[str] = None
language: Optional[str] = None
timezone: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""비밀번호 변경 요청"""
current_password: str
new_password: str
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 프로필 조회"""
return UserInfo.from_orm(current_user)
return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.put("/profile", response_model=UserInfo)
async def update_profile(
@router.put("/me", response_model=UserResponse)
async def update_current_user_profile(
profile_data: UpdateProfileRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""프로필 업데이트"""
update_data = {}
"""현재 사용자 프로필 업데이트"""
update_fields = profile_data.model_dump(exclude_unset=True)
if profile_data.full_name is not None:
update_data["full_name"] = profile_data.full_name
if profile_data.theme is not None:
update_data["theme"] = profile_data.theme
if profile_data.language is not None:
update_data["language"] = profile_data.language
if profile_data.timezone is not None:
update_data["timezone"] = profile_data.timezone
for field, value in update_fields.items():
setattr(current_user, field, value)
if update_data:
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(**update_data)
current_user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(current_user)
return UserResponse(
id=str(current_user.id),
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
is_admin=current_user.is_admin,
role=current_user.role,
can_manage_books=current_user.can_manage_books,
can_manage_notes=current_user.can_manage_notes,
can_manage_novels=current_user.can_manage_novels,
session_timeout_minutes=current_user.session_timeout_minutes,
theme=current_user.theme,
language=current_user.language,
timezone=current_user.timezone,
created_at=current_user.created_at,
updated_at=current_user.updated_at,
last_login=current_user.last_login
)
@router.post("/me/change-password")
async def change_current_user_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""현재 사용자 비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect"
)
await db.commit()
await db.refresh(current_user)
return UserInfo.from_orm(current_user)
# 새 비밀번호 설정
current_user.hashed_password = get_password_hash(password_data.new_password)
current_user.updated_at = datetime.utcnow()
await db.commit()
return {"message": "Password changed successfully"}
@router.get("/", response_model=List[UserInfo])
@router.get("/", response_model=List[UserResponse])
async def list_users(
admin_user: User = Depends(get_current_admin_user),
skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 목록 조회 (관리자 전용)"""
result = await db.execute(select(User).order_by(User.created_at.desc()))
result = await db.execute(
select(User)
.order_by(User.created_at.desc())
.offset(skip)
.limit(limit)
)
users = result.scalars().all()
return [UserInfo.from_orm(user) for user in users]
return [
UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
for user in users
]
@router.get("/{user_id}", response_model=UserInfo)
async def get_user(
user_id: str,
admin_user: User = Depends(get_current_admin_user),
@router.post("/", response_model=UserResponse)
async def create_user(
user_data: CreateUserRequest,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""특정 사용자 조회 (관리자 전용)"""
"""사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
existing_user = await db.execute(
select(User).where(User.email == user_data.email)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 권한 확인 (root만 admin/root 계정 생성 가능)
if user_data.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can create admin accounts"
)
# 사용자 생성
hashed_password = get_password_hash(user_data.password)
new_user = User(
email=user_data.email,
hashed_password=hashed_password,
full_name=user_data.full_name,
is_active=True,
is_admin=user_data.role in ["admin", "root"],
role=user_data.role,
can_manage_books=user_data.can_manage_books,
can_manage_notes=user_data.can_manage_notes,
can_manage_novels=user_data.can_manage_novels,
session_timeout_minutes=user_data.session_timeout_minutes
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserResponse(
id=str(new_user.id),
email=new_user.email,
full_name=new_user.full_name,
is_active=new_user.is_active,
is_admin=new_user.is_admin,
role=new_user.role,
can_manage_books=new_user.can_manage_books,
can_manage_notes=new_user.can_manage_notes,
can_manage_novels=new_user.can_manage_novels,
session_timeout_minutes=new_user.session_timeout_minutes,
theme=new_user.theme,
language=new_user.language,
timezone=new_user.timezone,
created_at=new_user.created_at,
updated_at=new_user.updated_at,
last_login=new_user.last_login
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: str,
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 상세 조회 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -96,18 +280,34 @@ async def get_user(
detail="User not found"
)
return UserInfo.from_orm(user)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.put("/{user_id}", response_model=UserInfo)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: str,
user_data: UpdateUserRequest,
admin_user: User = Depends(get_current_admin_user),
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 정보 업데이트 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -117,42 +317,55 @@ async def update_user(
detail="User not found"
)
# 자기 자신의 관리자 권한은 제거할 수 없음
if user.id == admin_user.id and user_data.is_admin is False:
# 권한 확인 (root만 admin/root 계정 수정 가능)
if user.role in ["admin", "root"] and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove admin privileges from yourself"
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can modify admin accounts"
)
# 업데이트할 데이터 준비
update_data = {}
if user_data.full_name is not None:
update_data["full_name"] = user_data.full_name
if user_data.is_active is not None:
update_data["is_active"] = user_data.is_active
if user_data.is_admin is not None:
update_data["is_admin"] = user_data.is_admin
# 업데이트할 필드들 적용
update_fields = user_data.model_dump(exclude_unset=True)
if update_data:
await db.execute(
update(User)
.where(User.id == user_id)
.values(**update_data)
)
await db.commit()
await db.refresh(user)
for field, value in update_fields.items():
if field == "role":
# 역할 변경 시 is_admin도 함께 업데이트
setattr(user, field, value)
user.is_admin = value in ["admin", "root"]
else:
setattr(user, field, value)
return UserInfo.from_orm(user)
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return UserResponse(
id=str(user.id),
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
role=user.role,
can_manage_books=user.can_manage_books,
can_manage_notes=user.can_manage_notes,
can_manage_novels=user.can_manage_novels,
session_timeout_minutes=user.session_timeout_minutes,
theme=user.theme,
language=user.language,
timezone=user.timezone,
created_at=user.created_at,
updated_at=user.updated_at,
last_login=user.last_login
)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
admin_user: User = Depends(get_current_admin_user),
current_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""사용자 삭제 (관리자 전용)"""
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@@ -162,15 +375,28 @@ async def delete_user(
detail="User not found"
)
# 자기 자신 삭제할 수 없음
if user.id == admin_user.id:
# 자기 자신 삭제 방지
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete yourself"
detail="Cannot delete your own account"
)
# root 계정 삭제 방지
if user.role == "root":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete root account"
)
# 권한 확인 (root만 admin 계정 삭제 가능)
if user.role == "admin" and current_user.role != "root":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only root users can delete admin accounts"
)
# 사용자 삭제
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
return {"message": "User deleted successfully"}
return {"message": "User deleted successfully"}

View File

@@ -24,11 +24,18 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str:
"""액세스 토큰 생성"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
elif timeout_minutes is not None:
if timeout_minutes == 0:
# 무제한 토큰 (1년으로 설정)
expire = datetime.utcnow() + timedelta(days=365)
else:
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)

View File

@@ -9,8 +9,7 @@ import uvicorn
from .core.config import settings
from .core.database import init_db
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes
from .api.routes.notes import router as note_documents_router
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
from .api.routes import note_documents, note_links
@@ -44,19 +43,19 @@ app.add_middleware(
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# API 라우터 등록
app.include_router(setup.router, prefix="/api/setup", tags=["시스템 설정"])
app.include_router(auth.router, prefix="/api/auth", tags=["인증"])
app.include_router(users.router, prefix="/api/users", tags=["사용자"])
app.include_router(documents.router, prefix="/api/documents", tags=["문서"])
app.include_router(highlights.router, prefix="/api/highlights", tags=["하이라이트"])
app.include_router(notes.router, prefix="/api/notes", tags=["메모"])
app.include_router(notes.router, prefix="/api/highlight-notes", tags=["하이라이트 메모"])
app.include_router(books.router, prefix="/api/books", tags=["서적"])
app.include_router(book_categories.router, prefix="/api/book-categories", tags=["서적 소분류"])
app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"])
app.include_router(search.router, prefix="/api/search", tags=["검색"])
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
app.include_router(note_documents_router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서 관리"])
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])

View File

@@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel):
tags: List[str] = Field(default=[])
is_published: bool = Field(default=False)
parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: int = Field(default=0)
class NoteDocumentCreate(NoteDocumentBase):
@@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel):
tags: Optional[List[str]] = None
is_published: Optional[bool] = None
parent_note_id: Optional[str] = None
notebook_id: Optional[str] = None
sort_order: Optional[int] = None
class NoteDocumentResponse(NoteDocumentBase):
@@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase):
'tags': obj.tags or [],
'is_published': obj.is_published,
'parent_note_id': str(obj.parent_note_id) if obj.parent_note_id else None,
'notebook_id': str(obj.notebook_id) if obj.notebook_id else None,
'sort_order': obj.sort_order,
'markdown_content': obj.markdown_content,
'created_at': obj.created_at,

View File

@@ -55,3 +55,4 @@ class NoteLink(Base):
def __repr__(self):
return f"<NoteLink(id={self.id}, source_note={self.source_note_id}, target_note={self.target_note_id})>"

View File

@@ -1,7 +1,7 @@
"""
사용자 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy import Column, String, Boolean, DateTime, Text, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -21,6 +21,17 @@ class User(Base):
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
# 권한 시스템 (서적관리, 노트관리, 소설관리)
can_manage_books = Column(Boolean, default=True) # 서적 관리 권한
can_manage_notes = Column(Boolean, default=True) # 노트 관리 권한
can_manage_novels = Column(Boolean, default=True) # 소설 관리 권한
# 사용자 역할 (root, admin, user)
role = Column(String(20), default="user") # root, admin, user
# 세션 타임아웃 설정 (분 단위, 0 = 무제한)
session_timeout_minutes = Column(Integer, default=5) # 기본 5분
# 메타데이터
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -33,10 +33,16 @@ class UserInfo(BaseModel):
full_name: Optional[str] = None
is_active: bool
is_admin: bool
role: str
can_manage_books: bool
can_manage_notes: bool
can_manage_novels: bool
session_timeout_minutes: int
theme: str
language: str
timezone: str
created_at: datetime
updated_at: Optional[datetime] = None
last_login: Optional[datetime] = None
class Config:

View File

@@ -89,3 +89,4 @@ jit_optimize_above_cost = 500000 # 최적화 JIT 비용 임계값
# 확장 모듈 설정
shared_preload_libraries = 'pg_stat_statements' # 쿼리 통계 모듈

View File

@@ -152,3 +152,4 @@ volumes:
type: none
o: bind
device: /volume2/document-storage

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

View File

@@ -1,5 +1,5 @@
<!-- 공통 헤더 컴포넌트 -->
<header class="header-modern fade-in">
<header class="header-modern fade-in fixed top-0 left-0 right-0 z-50">
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 -->
@@ -8,107 +8,230 @@
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
</div>
<!-- 메인 네비게이션 - 3가지 기능 -->
<nav class="flex space-x-6">
<!-- 문서 관리 시스템 -->
<!-- 메인 네비게이션 -->
<nav class="hidden md:flex items-center space-x-1 relative">
<!-- 문서 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="index.html" class="nav-link" id="doc-nav-link">
<i class="fas fa-folder-open"></i>
<button class="nav-link-modern" id="doc-nav-link">
<i class="fas fa-folder-open text-blue-600"></i>
<span>문서 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
</a>
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-2 gap-2">
<a href="index.html" class="nav-dropdown-card" id="index-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-th-large text-blue-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">문서 관리</div>
<div class="text-xs text-gray-500">HTML 문서 관리</div>
</div>
</div>
</a>
<a href="pdf-manager.html" class="nav-dropdown-card" id="pdf-manager-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-pdf text-red-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">PDF 관리</div>
<div class="text-xs text-gray-500">PDF 파일 관리</div>
</div>
</div>
</a>
</div>
</div>
</div>
<!-- 통합 검색 -->
<a href="search.html" class="nav-link" id="search-nav-link">
<i class="fas fa-search"></i>
<a href="search.html" class="nav-link-modern" id="search-nav-link">
<i class="fas fa-search text-green-600"></i>
<span>통합 검색</span>
</a>
<!-- 소설 관리 시스템 -->
<!-- 소설 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
<i class="fas fa-feather-alt"></i>
<button class="nav-link-modern" id="novel-nav-link">
<i class="fas fa-feather-alt text-purple-600"></i>
<span>소설 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
</a>
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-2 gap-2">
<a href="memo-tree.html" class="nav-dropdown-card" id="memo-tree-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-sitemap text-purple-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">트리 뷰</div>
<div class="text-xs text-gray-500">계층형 메모 관리</div>
</div>
</div>
</a>
<a href="story-view.html" class="nav-dropdown-card" id="story-view-nav-item">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book-open text-orange-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900">스토리 뷰</div>
<div class="text-xs text-gray-500">스토리 읽기 모드</div>
</div>
</div>
</a>
</div>
</div>
</div>
<!-- 노트 관리 시스템 -->
<!-- 노트 관리 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<a href="notes.html" class="nav-link" id="notes-nav-link">
<i class="fas fa-sticky-note"></i>
<button class="nav-link-modern" id="notes-nav-link">
<i class="fas fa-sticky-note text-yellow-600"></i>
<span>노트 관리</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</a>
<div x-show="open" x-transition class="nav-dropdown">
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
</a>
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
</a>
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
</a>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="nav-dropdown-wide">
<div class="grid grid-cols-3 gap-2">
<a href="notebooks.html" class="nav-dropdown-card" id="notebooks-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-book text-blue-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">노트북 관리</div>
<div class="text-xs text-gray-500">그룹 관리</div>
</div>
</div>
</a>
<a href="notes.html" class="nav-dropdown-card" id="notes-list-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-list text-green-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">노트 목록</div>
<div class="text-xs text-gray-500">전체 보기</div>
</div>
</div>
</a>
<a href="note-editor.html" class="nav-dropdown-card" id="note-editor-nav-item">
<div class="flex flex-col items-center text-center space-y-2">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-edit text-purple-600"></i>
</div>
<div>
<div class="font-semibold text-gray-900 text-sm">새 노트 작성</div>
<div class="text-xs text-gray-500">노트 만들기</div>
</div>
</div>
</a>
</div>
</div>
</div>
</nav>
<!-- 모바일 메뉴 버튼 -->
<div class="md:hidden">
<button x-data="{ open: false }" @click="open = !open" class="mobile-menu-btn">
<i class="fas fa-bars"></i>
</button>
</div>
<!-- 사용자 메뉴 -->
<div class="flex items-center space-x-4">
<!-- PDF 관리 버튼 -->
<a href="pdf-manager.html" class="nav-link" title="PDF 관리">
<i class="fas fa-file-pdf text-red-500"></i>
<span class="hidden sm:inline">PDF</span>
</a>
<!-- 언어 전환 버튼 -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<button class="nav-link" title="언어 설정">
<i class="fas fa-globe"></i>
<span class="hidden sm:inline">한국어</span>
<i class="fas fa-chevron-down text-xs ml-1"></i>
</button>
<div x-show="open" x-transition class="nav-dropdown">
<button class="nav-dropdown-item" onclick="handleLanguageChange('ko')">
<i class="fas fa-flag mr-2 text-blue-500"></i>한국어
</button>
<button class="nav-dropdown-item" onclick="handleLanguageChange('en')">
<i class="fas fa-flag mr-2 text-red-500"></i>English
</button>
</div>
</div>
<!-- 로그인/로그아웃 -->
<!-- 사용자 계정 메뉴 -->
<div class="flex items-center space-x-3" id="user-menu">
<!-- 로그인된 사용자 -->
<div class="hidden" id="logged-in-menu">
<span class="text-sm text-gray-600" id="user-name">User</span>
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
<i class="fas fa-sign-out-alt"></i> 로그아웃
<!-- 로그인된 사용자 드롭다운 -->
<div class="hidden relative" id="logged-in-menu" x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open" class="user-menu-btn">
<div class="w-9 h-9 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-sm">
<i class="fas fa-user text-white text-sm"></i>
</div>
<div class="hidden sm:block text-left">
<div class="text-sm font-semibold text-gray-900" id="user-name">User</div>
<div class="text-xs text-gray-500" id="user-role">사용자</div>
</div>
<i class="fas fa-chevron-down text-xs ml-1 transition-transform duration-200" :class="{ 'rotate-180': open }"></i>
</button>
<!-- 드롭다운 메뉴 -->
<div x-show="open" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="user-dropdown">
<!-- 사용자 정보 -->
<div class="px-4 py-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center shadow-md">
<i class="fas fa-user text-white"></i>
</div>
<div>
<div class="font-semibold text-gray-900" id="dropdown-user-name">User</div>
<div class="text-sm text-gray-600" id="dropdown-user-email">user@example.com</div>
<div class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 mt-1" id="dropdown-user-role">사용자</div>
</div>
</div>
</div>
<!-- 메뉴 항목들 -->
<div class="py-2">
<a href="profile.html" class="user-menu-item">
<i class="fas fa-user-edit text-blue-500"></i>
<div>
<div class="font-medium">프로필 관리</div>
<div class="text-xs text-gray-500">개인 정보 수정</div>
</div>
</a>
<a href="account-settings.html" class="user-menu-item">
<i class="fas fa-cog text-gray-500"></i>
<div>
<div class="font-medium">계정 설정</div>
<div class="text-xs text-gray-500">환경 설정 및 보안</div>
</div>
</a>
<!-- 관리자 메뉴 (관리자만 표시) -->
<div class="hidden" id="admin-menu-section">
<div class="border-t border-gray-100 my-2"></div>
<div class="px-4 py-2">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider">관리자 메뉴</div>
</div>
<a href="user-management.html" class="user-menu-item">
<i class="fas fa-users text-indigo-500"></i>
<div>
<div class="font-medium">사용자 관리</div>
<div class="text-xs text-gray-500">계정 및 권한 관리</div>
</div>
</a>
<a href="system-settings.html" class="user-menu-item">
<i class="fas fa-server text-green-500"></i>
<div>
<div class="font-medium">시스템 설정</div>
<div class="text-xs text-gray-500">시스템 전체 설정</div>
</div>
</a>
</div>
<!-- 로그아웃 -->
<div class="border-t border-gray-100 my-2"></div>
<button onclick="handleLogout()" class="user-menu-item text-red-600 hover:bg-red-50 w-full">
<i class="fas fa-sign-out-alt text-red-500"></i>
<div>
<div class="font-medium">로그아웃</div>
<div class="text-xs text-gray-500">계정에서 로그아웃</div>
</div>
</button>
</div>
</div>
</div>
<!-- 로그인 버튼 -->
<div class="hidden" id="login-button">
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
<i class="fas fa-sign-in-alt"></i> 로그인
<div class="" id="login-button">
<button id="login-btn" class="login-btn-modern">
<i class="fas fa-sign-in-alt"></i>
<span>로그인</span>
</button>
</div>
</div>
@@ -119,35 +242,128 @@
<!-- 헤더 관련 스타일 -->
<style>
/* 네비게이션 링크 스타일 */
.nav-link {
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
/* 모던 네비게이션 링크 스타일 */
.nav-link-modern {
@apply flex items-center space-x-2 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
}
.nav-link.active {
@apply text-blue-600 bg-blue-50 font-medium;
.nav-link-modern:hover {
@apply bg-gradient-to-r from-gray-50 to-gray-100 shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.nav-link:hover {
@apply bg-gray-50;
.nav-link-modern.active {
@apply text-blue-700 bg-blue-50 border-blue-200;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
}
/* 드롭다운 메뉴 스타일 */
.nav-dropdown {
@apply absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-2 min-w-44 z-50;
/* 와이드 드롭다운 메뉴 스타일 */
.nav-dropdown-wide {
position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
margin-top: 0.5rem !important;
z-index: 9999 !important;
min-width: 400px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(15px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
padding: 1rem;
pointer-events: auto;
}
.nav-dropdown-item {
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
.nav-dropdown-card {
@apply block p-4 bg-white border border-gray-100 rounded-lg hover:border-gray-200 hover:shadow-md transition-all duration-200 cursor-pointer;
}
.nav-dropdown-item.active {
@apply text-blue-600 bg-blue-50 font-medium;
.nav-dropdown-card:hover {
@apply bg-gradient-to-br from-gray-50 to-blue-50 transform -translate-y-1;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.nav-dropdown-card.active {
@apply border-blue-200 bg-blue-50;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
}
/* 드롭다운 컨테이너 안정성 */
nav > div.relative {
position: relative !important;
display: inline-block !important;
}
/* 애니메이션 중 위치 고정 */
.nav-dropdown-wide[x-show] {
position: absolute !important;
top: 100% !important;
left: 50% !important;
transform: translateX(-50%) !important;
}
/* 모바일 메뉴 버튼 */
.mobile-menu-btn {
@apply p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200;
}
/* 사용자 메뉴 스타일 */
.user-menu-btn {
@apply flex items-center space-x-3 px-3 py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-50 rounded-lg transition-all duration-200 cursor-pointer;
border: 1px solid transparent;
}
.user-menu-btn:hover {
@apply shadow-sm;
border-color: rgba(0, 0, 0, 0.05);
}
.user-dropdown {
@apply absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl py-0 z-50;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.user-menu-item {
@apply flex items-center space-x-3 px-4 py-3 text-sm text-gray-700 hover:bg-gray-50 transition-all duration-150 cursor-pointer;
}
.user-menu-item:hover {
@apply bg-gradient-to-r from-gray-50 to-blue-50 text-gray-900;
transform: translateX(2px);
}
.user-menu-item i {
@apply w-5 h-5 flex items-center justify-center;
}
/* 로그인 버튼 모던 스타일 */
.login-btn-modern {
@apply flex items-center space-x-2 px-4 py-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
}
.login-btn-modern:hover {
@apply from-blue-700 to-blue-800 transform -translate-y-0.5;
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
}
.login-btn-modern:active {
@apply transform translate-y-0;
}
/* 헤더 모던 스타일 */
.header-modern {
@apply bg-white border-b border-gray-200 shadow-sm;
@apply bg-white/95 backdrop-blur-md border-b border-gray-200/50 shadow-lg;
transition: all 0.3s ease;
}
/* 헤더 호버 효과 */
.header-modern:hover {
@apply bg-white shadow-xl;
}
/* 언어 전환 스타일 */
@@ -218,34 +434,9 @@
// 로그인 관련 함수들
window.handleLogin = () => {
console.log('🔐 handleLogin 호출됨');
// Alpine.js 컨텍스트에서 함수 찾기
const bodyElement = document.querySelector('body');
if (bodyElement && bodyElement._x_dataStack) {
const alpineData = bodyElement._x_dataStack[0];
if (alpineData && typeof alpineData.openLoginModal === 'function') {
console.log('✅ Alpine 컨텍스트에서 openLoginModal 호출');
alpineData.openLoginModal();
return;
}
if (alpineData && alpineData.showLoginModal !== undefined) {
console.log('✅ Alpine 컨텍스트에서 showLoginModal 설정');
alpineData.showLoginModal = true;
return;
}
}
// 전역 함수로 시도
if (typeof window.openLoginModal === 'function') {
console.log('✅ 전역 openLoginModal 호출');
window.openLoginModal();
return;
}
// 직접 이벤트 발생
console.log('🔄 커스텀 이벤트로 로그인 모달 열기');
document.dispatchEvent(new CustomEvent('open-login-modal'));
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
const currentUrl = encodeURIComponent(window.location.href);
window.location.href = `login.html?redirect=${currentUrl}`;
};
window.handleLogout = () => {
@@ -259,22 +450,73 @@
// 사용자 상태 업데이트 함수
window.updateUserMenu = (user) => {
console.log('🔄 updateUserMenu 호출됨:', user);
const loggedInMenu = document.getElementById('logged-in-menu');
const loginButton = document.getElementById('login-button');
const adminMenuSection = document.getElementById('admin-menu-section');
console.log('🔍 요소 찾기:', {
loggedInMenu: !!loggedInMenu,
loginButton: !!loginButton,
adminMenuSection: !!adminMenuSection
});
// 사용자 정보 요소들
const userName = document.getElementById('user-name');
const userRole = document.getElementById('user-role');
const dropdownUserName = document.getElementById('dropdown-user-name');
const dropdownUserEmail = document.getElementById('dropdown-user-email');
const dropdownUserRole = document.getElementById('dropdown-user-role');
if (user) {
// 로그인된 상태
if (loggedInMenu) loggedInMenu.classList.remove('hidden');
if (loginButton) loginButton.classList.add('hidden');
if (userName) userName.textContent = user.username || user.full_name || user.email || 'User';
console.log('✅ 사용자 로그인 상태 - UI 업데이트 시작');
if (loggedInMenu) {
loggedInMenu.classList.remove('hidden');
console.log('✅ 로그인 메뉴 표시');
}
if (loginButton) {
loginButton.classList.add('hidden');
console.log('✅ 로그인 버튼 숨김');
}
// 사용자 정보 업데이트
const displayName = user.full_name || user.email || 'User';
const roleText = getRoleText(user.role);
if (userName) userName.textContent = displayName;
if (userRole) userRole.textContent = roleText;
if (dropdownUserName) dropdownUserName.textContent = displayName;
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
// 관리자 메뉴 표시/숨김
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
adminMenuSection.classList.remove('hidden');
} else {
adminMenuSection.classList.add('hidden');
}
}
} else {
// 로그아웃된 상태
if (loggedInMenu) loggedInMenu.classList.add('hidden');
if (loginButton) loginButton.classList.remove('hidden');
if (adminMenuSection) adminMenuSection.classList.add('hidden');
}
};
// 역할 텍스트 변환 함수
function getRoleText(role) {
const roleMap = {
'root': '시스템 관리자',
'admin': '관리자',
'user': '사용자'
};
return roleMap[role] || '사용자';
}
// 언어 토글 함수 (전역)
// 통합 언어 변경 함수
window.handleLanguageChange = (lang) => {
@@ -335,9 +577,27 @@
// 향후 다국어 지원 시 구현
};
// 헤더 로드 완료 후 언어 설정 적용
// 헤더 로드 완료 후 이벤트 바인딩
document.addEventListener('headerLoaded', () => {
console.log('🔧 헤더 로드 완료 - 언어 전환 함수 등록');
console.log('🔧 헤더 로드 완료 - 이벤트 바인딩 시작');
// 로그인 버튼 이벤트 리스너 추가
const loginBtn = document.getElementById('login-btn');
if (loginBtn) {
loginBtn.addEventListener('click', () => {
console.log('🔐 로그인 버튼 클릭됨');
if (typeof window.handleLogin === 'function') {
window.handleLogin();
} else {
console.error('❌ handleLogin 함수를 찾을 수 없습니다');
}
});
console.log('✅ 로그인 버튼 이벤트 리스너 등록 완료');
}
// 언어 설정 적용
const savedLang = localStorage.getItem('preferred_language') || 'ko';
console.log('💾 저장된 언어 설정 적용:', savedLang);

View File

@@ -10,6 +10,9 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/main.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- 헤더 로더 -->
<script src="static/js/header-loader.js"></script>
</head>

316
frontend/login.html Normal file
View 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>&copy; 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>

View File

@@ -780,7 +780,7 @@
class="absolute tree-diagram-node"
:style="getNodePosition(node)"
:data-node-id="node.id"
@mousedown="startDragNode($event, node)"
@click="selectNode(node)"
>
<div
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
@@ -788,7 +788,6 @@
'tree-node-canonical': node.is_canonical,
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
}"
@click="selectNode(node)"
@dblclick="editNodeInline(node)"
>
<!-- 정사 경로 배지 -->

363
frontend/profile.html Normal file
View 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>

View File

@@ -8,6 +8,9 @@
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- 인증 가드 -->
<script src="static/js/auth-guard.js"></script>
<!-- PDF.js 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
@@ -251,6 +254,25 @@
</button>
</div>
<!-- 파일 타입 필터 -->
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-700">파일 타입:</span>
<button
@click="fileTypeFilter = fileTypeFilter === 'PDF' ? '' : 'PDF'; applyFilters()"
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
:class="fileTypeFilter === 'PDF' ? 'bg-red-100 text-red-800 border-red-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-file-pdf mr-1"></i>PDF
</button>
<button
@click="fileTypeFilter = fileTypeFilter === 'HTML' ? '' : 'HTML'; applyFilters()"
class="search-filter-chip px-2 py-1 rounded text-xs border transition-all"
:class="fileTypeFilter === 'HTML' ? 'bg-orange-100 text-orange-800 border-orange-300' : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'"
>
<i class="fas fa-code mr-1"></i>HTML
</button>
</div>
<!-- 정렬 -->
<div class="flex items-center space-x-2 ml-auto">
<span class="text-sm font-medium text-gray-700">정렬:</span>
@@ -314,7 +336,7 @@
<!-- 검색 결과 -->
<div x-show="!loading && filteredResults.length > 0" class="max-w-4xl mx-auto space-y-4">
<template x-for="result in filteredResults" :key="result.id">
<template x-for="result in filteredResults" :key="result.unique_id || result.id">
<div class="search-result-card bg-white rounded-lg shadow-sm border p-6 fade-in">
<!-- 결과 헤더 -->
<div class="flex items-start justify-between mb-3">
@@ -323,6 +345,29 @@
<span class="result-type-badge"
:class="`badge-${result.type}`"
x-text="getTypeLabel(result.type)"></span>
<!-- 파일 타입 정보 (PDF/HTML) -->
<span x-show="result.highlight_info?.file_type"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="{
'bg-red-100 text-red-800': result.highlight_info?.file_type === 'PDF',
'bg-orange-100 text-orange-800': result.highlight_info?.file_type === 'HTML'
}">
<i class="fas mr-1"
:class="{
'fa-file-pdf': result.highlight_info?.file_type === 'PDF',
'fa-code': result.highlight_info?.file_type === 'HTML'
}"></i>
<span x-text="result.highlight_info?.file_type"></span>
</span>
<!-- 매치 개수 -->
<span x-show="result.highlight_info && result.highlight_info.match_count > 0"
class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
<i class="fas fa-search mr-1"></i>
<span x-text="result.highlight_info?.match_count || 0"></span>개 매치
</span>
<span class="text-xs text-gray-500" x-text="formatDate(result.created_at)"></span>
<div x-show="result.relevance_score > 0" class="flex items-center text-xs text-gray-500">
<i class="fas fa-star text-yellow-500 mr-1"></i>
@@ -483,7 +528,8 @@
<!-- PDF 뷰어 컨테이너 -->
<div class="border rounded-lg overflow-hidden bg-gray-100 relative" style="height: 500px;">
<!-- PDF iframe 뷰어 -->
<iframe x-show="!pdfError && !pdfLoading"
<iframe id="pdf-preview-iframe"
x-show="!pdfError && !pdfLoading"
class="w-full h-full border-0"
:src="pdfSrc"
@load="pdfLoaded = true"

274
frontend/setup.html Normal file
View 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>

View File

@@ -1,6 +1,7 @@
/* 메인 스타일 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding-top: 4rem; /* 고정 헤더를 위한 패딩 */
}
/* 알림 애니메이션 */

View File

@@ -0,0 +1,41 @@
# 로그인 페이지 이미지
이 폴더에는 로그인 페이지에서 사용되는 배경 이미지를 저장합니다.
## 필요한 이미지 파일
### 배경 이미지
- `login-bg.jpg` - 전체 페이지 배경 이미지 (권장 크기: 1920x1080px 이상)
## 이미지 사양
- **형식**: JPG, PNG 지원
- **품질**: 웹 최적화된 고품질 이미지
- **용량**: 1MB 이하 권장
- **비율**: 16:9 또는 16:10 비율 권장
- **색상**: 어두운 톤 또는 블러 처리된 이미지 권장 (텍스트 가독성을 위해)
## 폴백 동작
배경 이미지 파일이 없는 경우:
- 파란색-보라색 그라디언트 배경으로 자동 폴백
## 사용 예시
```
static/images/
└── login-bg.jpg (전체 배경)
```
## 배경 이미지 선택 가이드
- **문서/도서관 테마**: 책장, 도서관, 서재 등
- **기술/현대적 테마**: 추상적 패턴, 기하학적 형태
- **자연 테마**: 차분한 풍경, 블러 처리된 자연 이미지
- **미니멀 테마**: 단순한 패턴, 텍스처
## 변경 사항 (v2.0)
- 갤러리 액자 기능 제거
- 중앙 집중형 로그인 레이아웃으로 변경
- 배경 이미지만 사용하는 심플한 디자인

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

View File

@@ -201,7 +201,7 @@ class DocumentServerAPI {
}
async getCurrentUser() {
return await this.get('/auth/me');
return await this.get('/users/me');
}
async refreshToken(refreshToken) {

View 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
};
})();

View File

@@ -155,9 +155,97 @@ document.addEventListener('headerLoaded', () => {
setTimeout(() => {
window.headerLoader.updateActiveStates();
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
if (typeof window.updateUserMenu === 'function') {
window.updateUserMenu(null);
// updateUserMenu 함수 정의 (헤더 로더에서 직접 정의)
if (typeof window.updateUserMenu === 'undefined') {
window.updateUserMenu = (user) => {
console.log('🔄 updateUserMenu 호출됨:', user);
const loggedInMenu = document.getElementById('logged-in-menu');
const loginButton = document.getElementById('login-button');
const adminMenuSection = document.getElementById('admin-menu-section');
// 사용자 정보 요소들
const userName = document.getElementById('user-name');
const userRole = document.getElementById('user-role');
const dropdownUserName = document.getElementById('dropdown-user-name');
const dropdownUserEmail = document.getElementById('dropdown-user-email');
const dropdownUserRole = document.getElementById('dropdown-user-role');
if (user) {
// 로그인된 상태
console.log('✅ 사용자 로그인 상태 - UI 업데이트');
if (loggedInMenu) {
loggedInMenu.classList.remove('hidden');
console.log('✅ 로그인 메뉴 표시');
}
if (loginButton) {
loginButton.classList.add('hidden');
console.log('✅ 로그인 버튼 숨김');
}
// 사용자 정보 업데이트
const displayName = user.full_name || user.email || 'User';
const roleText = user.role === 'root' ? '시스템 관리자' :
user.role === 'admin' ? '관리자' : '사용자';
if (userName) userName.textContent = displayName;
if (userRole) userRole.textContent = roleText;
if (dropdownUserName) dropdownUserName.textContent = displayName;
if (dropdownUserEmail) dropdownUserEmail.textContent = user.email || '';
if (dropdownUserRole) dropdownUserRole.textContent = roleText;
// 관리자 메뉴 표시/숨김
if (adminMenuSection) {
if (user.role === 'root' || user.role === 'admin' || user.is_admin) {
adminMenuSection.classList.remove('hidden');
} else {
adminMenuSection.classList.add('hidden');
}
}
} else {
// 로그아웃된 상태
console.log('❌ 로그아웃 상태');
if (loggedInMenu) loggedInMenu.classList.add('hidden');
if (loginButton) loginButton.classList.remove('hidden');
if (adminMenuSection) adminMenuSection.classList.add('hidden');
}
};
console.log('✅ updateUserMenu 함수 정의 완료');
}
// 사용자 메뉴 상태 설정 (현재 로그인 상태 확인)
setTimeout(() => {
// 전역 사용자 정보가 있으면 사용, 없으면 토큰으로 확인
if (window.currentUser) {
window.updateUserMenu(window.currentUser);
} else {
// 토큰이 있으면 사용자 정보 다시 가져오기
const token = localStorage.getItem('access_token');
if (token) {
fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(response => response.ok ? response.json() : null)
.then(user => {
if (user) {
window.currentUser = user;
window.updateUserMenu(user);
} else {
window.updateUserMenu(null);
}
})
.catch(() => window.updateUserMenu(null));
} else {
window.updateUserMenu(null);
}
}
}, 200);
// 전역 함수들이 정의되지 않은 경우 빈 함수로 초기화
if (typeof window.handleLanguageChange === 'undefined') {
window.handleLanguageChange = function(lang) {
console.log('언어 변경 함수가 아직 로드되지 않았습니다:', lang);
};
}
}, 100);
});

View File

@@ -65,6 +65,15 @@ window.documentApp = () => ({
this.isAuthenticated = false;
this.currentUser = null;
localStorage.removeItem('access_token');
// 로그인 페이지로 리다이렉트 (setup.html 제외)
if (!window.location.pathname.includes('setup.html') &&
!window.location.pathname.includes('login.html')) {
const currentUrl = encodeURIComponent(window.location.href);
window.location.href = `login.html?redirect=${currentUrl}`;
return;
}
this.syncUIState(); // UI 상태 동기화
}
},

View File

@@ -63,9 +63,6 @@ window.memoTreeApp = function() {
treePanX: 0,
treePanY: 0,
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
isDragging: false,
dragNode: null,
dragOffset: { x: 0, y: 0 },
// 로그인 관련 함수들
openLoginModal() {
@@ -290,7 +287,15 @@ window.memoTreeApp = function() {
const node = await window.api.createMemoNode(nodeData);
this.treeNodes.push(node);
this.selectNode(node);
// 노드 위치 재계산 (새 노드 추가 후)
this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 루트 노드 생성 완료');
} catch (error) {
@@ -301,6 +306,11 @@ window.memoTreeApp = function() {
// 노드 선택
selectNode(node) {
// 현재 팬 값 저장 (위치 변경 방지)
const currentPanX = this.treePanX;
const currentPanY = this.treePanY;
const currentZoom = this.treeZoom;
// 이전 노드 저장
if (this.selectedNode && this.isEditorDirty) {
this.saveNode();
@@ -323,6 +333,11 @@ window.memoTreeApp = function() {
}, 100);
}
// 팬 값 복원 (위치 변경 방지)
this.treePanX = currentPanX;
this.treePanY = currentPanY;
this.treeZoom = currentZoom;
console.log('📝 노드 선택:', node.title);
},
@@ -552,8 +567,14 @@ window.memoTreeApp = function() {
// 부모 노드 펼치기
this.expandedNodes.add(parentNode.id);
// 새 노드 선택
this.selectNode(node);
// 노드 위치 재계산 (새 노드 추가 후)
this.$nextTick(() => {
this.calculateNodePositions();
// 위치 계산 완료 후 새 노드 선택
setTimeout(() => {
this.selectNode(node);
}, 50);
});
console.log('✅ 자식 노드 생성 완료');
} catch (error) {
@@ -623,123 +644,9 @@ window.memoTreeApp = function() {
}
},
// 노드 드래그 시작
startDragNode(event, node) {
event.stopPropagation();
event.preventDefault();
console.log('🎯 노드 드래그 시작:', node.title);
this.isDragging = true;
this.dragNode = node;
// 드래그 시작 위치 계산
const rect = event.target.getBoundingClientRect();
this.dragOffset = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// 드래그 중인 노드 스타일 적용
event.target.style.opacity = '0.7';
event.target.style.transform = 'scale(1.05)';
event.target.style.zIndex = '1000';
// 이벤트 리스너 등록
document.addEventListener('mousemove', this.handleNodeDrag.bind(this));
document.addEventListener('mouseup', this.endNodeDrag.bind(this));
},
// 노드 드래그 처리
handleNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
event.preventDefault();
// 드래그 중인 노드 위치 업데이트
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
const newX = event.clientX - this.dragOffset.x;
const newY = event.clientY - this.dragOffset.y;
dragElement.style.position = 'fixed';
dragElement.style.left = `${newX}px`;
dragElement.style.top = `${newY}px`;
dragElement.style.pointerEvents = 'none';
}
// 드롭 대상 하이라이트
this.highlightDropTarget(event);
},
// 드롭 대상 하이라이트
highlightDropTarget(event) {
// 모든 노드에서 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 현재 마우스 위치의 노드 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetNode = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
if (targetNode) {
targetNode.classList.add('drop-target-highlight');
}
},
// 노드 드래그 종료
endNodeDrag(event) {
if (!this.isDragging || !this.dragNode) return;
console.log('🎯 노드 드래그 종료');
// 드롭 대상 찾기
const elements = document.elementsFromPoint(event.clientX, event.clientY);
const targetElement = elements.find(el =>
el.classList.contains('tree-diagram-node') &&
el.getAttribute('data-node-id') !== this.dragNode.id
);
let targetNodeId = null;
if (targetElement) {
targetNodeId = targetElement.getAttribute('data-node-id');
}
// 드래그 중인 노드 스타일 복원
const dragElement = document.querySelector(`[data-node-id="${this.dragNode.id}"]`);
if (dragElement) {
dragElement.style.opacity = '';
dragElement.style.transform = '';
dragElement.style.zIndex = '';
dragElement.style.position = '';
dragElement.style.left = '';
dragElement.style.top = '';
dragElement.style.pointerEvents = '';
}
// 모든 하이라이트 제거
document.querySelectorAll('.tree-diagram-node').forEach(el => {
el.classList.remove('drop-target-highlight');
});
// 실제 노드 이동 처리
if (targetNodeId && targetNodeId !== this.dragNode.id) {
this.moveNodeToParent(this.dragNode.id, targetNodeId);
}
// 상태 초기화
this.isDragging = false;
this.dragNode = null;
this.dragOffset = { x: 0, y: 0 };
// 이벤트 리스너 제거
document.removeEventListener('mousemove', this.handleNodeDrag);
document.removeEventListener('mouseup', this.endNodeDrag);
},
// 노드를 다른 부모로 이동
async moveNodeToParent(nodeId, newParentId) {
@@ -783,15 +690,12 @@ window.memoTreeApp = function() {
// 노드 위치 계산 및 반환
getNodePosition(node) {
if (!this.nodePositions.has(node.id)) {
this.calculateNodePositions();
}
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
return `left: ${pos.x}px; top: ${pos.y}px;`;
},
// 트리 노드 위치 자동 계산
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
calculateNodePositions() {
const canvas = document.getElementById('tree-canvas');
if (!canvas) return;
@@ -802,11 +706,11 @@ window.memoTreeApp = function() {
// 노드 크기 설정
const nodeWidth = 200;
const nodeHeight = 80;
const levelHeight = 150; // 레벨 간 간격
const nodeSpacing = 50; // 노드 간 간격
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
const nodeSpacing = 100; // 노드 간 세로 간격
const margin = 100; // 여백
// 레벨별 노드 그룹화
// 레벨별 노드 그룹화 (가로 방향)
const levels = new Map();
// 루트 노드들 찾기
@@ -814,7 +718,7 @@ window.memoTreeApp = function() {
if (rootNodes.length === 0) return;
// BFS로 레벨별 노드 배치
// BFS로 레벨별 노드 배치 (가로 방향)
const queue = [];
rootNodes.forEach(node => {
queue.push({ node, level: 0 });
@@ -835,25 +739,25 @@ window.memoTreeApp = function() {
});
}
// 트리 전체 크기 계산
// 트리 전체 크기 계산 (가로 방향)
const maxLevel = Math.max(...levels.keys());
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
const treeHeight = (maxLevel + 1) * levelHeight;
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
// 각 레벨의 노드들 위치 계산
// 각 레벨의 노드들 위치 계산 (가로 방향)
levels.forEach((nodes, level) => {
const y = offsetY + level * levelHeight;
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
const startX = offsetX + (treeWidth - levelWidth) / 2;
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
nodes.forEach((node, index) => {
const x = startX + index * (nodeWidth + nodeSpacing);
const y = startY + index * (nodeHeight + nodeSpacing);
this.nodePositions.set(node.id, { x, y });
});
});
@@ -893,17 +797,17 @@ window.memoTreeApp = function() {
// 연결선 생성
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// 부모 노드 하단 중앙에서 시작
const startX = parentPos.x + 100; // 노드 중앙
const startY = parentPos.y + 80; // 노드 하단
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
const startX = parentPos.x + 200; // 노드 오른쪽 끝
const startY = parentPos.y + 40; // 노드 세로 중앙
// 자식 노드 상단 중앙으로 연결
const endX = childPos.x + 100; // 노드 중앙
const endY = childPos.y; // 노드 상단
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
const endX = childPos.x; // 노드 왼쪽 끝
const endY = childPos.y + 40; // 노드 세로 중앙
// 곡선 경로 생성 (베지어 곡선)
const midY = startY + (endY - startY) / 2;
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
const midX = startX + (endX - startX) / 2;
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
line.setAttribute('d', path);
line.setAttribute('stroke', '#9CA3AF');

View File

@@ -15,6 +15,7 @@ window.searchApp = function() {
// 필터링
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
fileTypeFilter: '', // '', 'PDF', 'HTML'
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
// 검색 디바운스
@@ -157,9 +158,43 @@ window.searchApp = function() {
applyFilters() {
let results = [...this.searchResults];
// 중복 ID 제거 (같은 문서의 document와 document_content가 중복될 수 있음)
const uniqueResults = [];
const seenIds = new Set();
results.forEach(result => {
const uniqueKey = `${result.type}-${result.id}`;
if (!seenIds.has(uniqueKey)) {
seenIds.add(uniqueKey);
uniqueResults.push({
...result,
unique_id: uniqueKey // Alpine.js x-for 키로 사용
});
}
});
results = uniqueResults;
// 타입 필터
if (this.typeFilter) {
results = results.filter(result => result.type === this.typeFilter);
results = results.filter(result => {
// 문서 타입은 document와 document_content 모두 포함
if (this.typeFilter === 'document') {
return result.type === 'document' || result.type === 'document_content';
}
// 하이라이트 타입은 highlight와 highlight_note 모두 포함
if (this.typeFilter === 'highlight') {
return result.type === 'highlight' || result.type === 'highlight_note';
}
return result.type === this.typeFilter;
});
}
// 파일 타입 필터
if (this.fileTypeFilter) {
results = results.filter(result => {
return result.highlight_info?.file_type === this.fileTypeFilter;
});
}
// 정렬
@@ -179,7 +214,7 @@ window.searchApp = function() {
});
this.filteredResults = results;
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과 (타입:', this.typeFilter, ', 파일타입:', this.fileTypeFilter, ')');
},
// URL 업데이트
@@ -339,22 +374,23 @@ window.searchApp = function() {
this.pdfLoaded = false;
try {
// PDF 파일 존재 여부 먼저 확인
const response = await fetch(`/api/documents/${documentId}/pdf`, {
method: 'HEAD',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
// PDF 파일 src 직접 설정 (HEAD 요청 대신)
const token = localStorage.getItem('access_token');
console.log('🔍 토큰 디버깅:', {
token: token,
tokenType: typeof token,
tokenLength: token ? token.length : 0,
isNull: token === null,
isStringNull: token === 'null',
localStorage: Object.keys(localStorage)
});
if (response.ok) {
// PDF 파일이 존재하면 src 설정
const token = localStorage.getItem('token');
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
console.log('PDF 미리보기 준비 완료:', this.pdfSrc);
} else {
throw new Error(`PDF 파일을 찾을 수 없습니다 (${response.status})`);
if (!token || token === 'null' || token === null) {
console.error('❌ 토큰 문제:', token);
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
}
this.pdfSrc = `/api/documents/${documentId}/pdf?_token=${encodeURIComponent(token)}`;
console.log('✅ PDF 미리보기 준비 완료:', this.pdfSrc);
} catch (error) {
console.error('PDF 미리보기 로드 실패:', error);
this.pdfError = true;
@@ -370,6 +406,50 @@ window.searchApp = function() {
this.pdfLoading = false;
},
// PDF에서 검색어 찾기 (브라우저 내장 검색 활용)
searchInPdf() {
if (this.searchQuery && this.pdfLoaded) {
// iframe 내에서 검색 실행 (Ctrl+F 시뮬레이션)
const iframe = document.querySelector('#pdf-preview-iframe');
if (iframe && iframe.contentWindow) {
try {
iframe.contentWindow.focus();
// 브라우저 검색 창 열기 시도
if (iframe.contentWindow.find) {
iframe.contentWindow.find(this.searchQuery);
} else {
// 대안: 사용자에게 수동 검색 안내
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
}
} catch (e) {
// 보안상 직접 접근이 안 되는 경우, 사용자에게 안내
this.showNotification(`PDF에서 "${this.searchQuery}"를 찾으려면 Ctrl+F를 눌러주세요.`, 'info');
}
}
}
},
// 알림 표시 (간단한 토스트)
showNotification(message, type = 'info') {
// 간단한 알림 구현 (실제로는 더 정교한 토스트 시스템을 사용할 수 있음)
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
type === 'info' ? 'bg-blue-500' :
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-gray-500'
}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3초 후 자동 제거
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
},
// HTML 미리보기 로드
async loadHtmlPreview(documentId) {
this.htmlLoading = true;
@@ -385,7 +465,12 @@ window.searchApp = function() {
const iframe = document.getElementById('htmlPreviewFrame');
if (iframe) {
// iframe src를 직접 설정 (인증 헤더 포함)
const token = localStorage.getItem('token');
const token = localStorage.getItem('access_token');
console.log('🔍 HTML 미리보기 토큰:', token ? '있음' : '없음', token);
if (!token || token === 'null' || token === null) {
console.error('❌ HTML 미리보기 토큰 문제:', token);
throw new Error('인증 토큰이 없습니다.');
}
iframe.src = `/api/documents/${documentId}/content?_token=${encodeURIComponent(token)}`;
// iframe 로드 완료 후 검색어 하이라이트

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

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

View File

@@ -28,6 +28,24 @@ server {
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 리다이렉트 방지
proxy_redirect off;
# CORS 헤더 추가
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
# OPTIONS 요청 처리
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
}
# 업로드된 문서 파일 서빙

33
scripts/backup.sh Executable file
View 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
View 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 "🎉 복원 작업 완료"

Binary file not shown.

Binary file not shown.