Files
document-server/frontend/user-management.html
Hyungi Ahn 6e01dbdeb3 Fix: 업로드 및 API 연결 문제 해결
- FastAPI 라우터에서 슬래시 문제로 인한 307 리다이렉트 수정
- Nginx 프록시 설정에서 경로 중복 문제 해결
- 계정 관리 시스템 구현 (로그인, 사용자 관리, 권한 설정)
- 노트북 연결 기능 수정 (notebook_id 필드 추가)
- 메모 트리 UI 개선 (수평 레이아웃, 드래그 기능 제거)
- 헤더 UI 개선 및 고정 위치 설정
- 백업/복원 스크립트 추가
- PDF 미리보기 토큰 인증 지원
2025-09-03 15:58:10 +09:00

521 lines
29 KiB
HTML

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