From 17de53b9c2fe692469db73f0ad116b288ef89dea Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 10 Nov 2025 01:56:44 +0100 Subject: [PATCH] feat: Implement admin dashboard user management (CRUD) --- rbox/routers/admin.py | 98 +++++++++++++ static/js/api.js | 124 ++++++++++++++++- static/js/components/admin-dashboard.js | 174 ++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 rbox/routers/admin.py create mode 100644 static/js/components/admin-dashboard.js diff --git a/rbox/routers/admin.py b/rbox/routers/admin.py new file mode 100644 index 0000000..809043d --- /dev/null +++ b/rbox/routers/admin.py @@ -0,0 +1,98 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from ..auth import get_current_admin_user, get_password_hash +from ..models import User, User_Pydantic +from ..schemas import UserCreate, UserAdminUpdate + +router = APIRouter( + prefix="/admin", + tags=["admin"], + dependencies=[Depends(get_current_admin_user)], + responses={403: {"description": "Not enough permissions"}}, +) + +@router.get("/users", response_model=List[User_Pydantic]) +async def get_all_users(): + return await User.all() + +@router.get("/users/{user_id}", response_model=User_Pydantic) +async def get_user(user_id: int): + user = await User.get_or_none(id=user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + +@router.post("/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED) +async def create_user_by_admin(user_in: UserCreate): + user = await User.get_or_none(username=user_in.username) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already registered", + ) + user = await User.get_or_none(email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + + hashed_password = get_password_hash(user_in.password) + user = await User.create( + username=user_in.username, + email=user_in.email, + hashed_password=hashed_password, + is_superuser=False, # Admin creates regular users by default + is_active=True, + ) + return await User_Pydantic.from_tortoise_orm(user) + +@router.put("/users/{user_id}", response_model=User_Pydantic) +async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate): + user = await User.get_or_none(id=user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if user_update.username is not None and user_update.username != user.username: + if await User.get_or_none(username=user_update.username): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken") + user.username = user_update.username + + if user_update.email is not None and user_update.email != user.email: + if await User.get_or_none(email=user_update.email): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") + user.email = user_update.email + + if user_update.password is not None: + user.hashed_password = get_password_hash(user_update.password) + + if user_update.is_active is not None: + user.is_active = user_update.is_active + + if user_update.is_superuser is not None: + user.is_superuser = user_update.is_superuser + + if user_update.storage_quota_bytes is not None: + user.storage_quota_bytes = user_update.storage_quota_bytes + + if user_update.plan_type is not None: + user.plan_type = user_update.plan_type + + if user_update.is_2fa_enabled is not None: + user.is_2fa_enabled = user_update.is_2fa_enabled + if not user_update.is_2fa_enabled: + user.two_factor_secret = None + user.recovery_codes = None + + await user.save() + return await User_Pydantic.from_tortoise_orm(user) + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user_by_admin(user_id: int): + user = await User.get_or_none(id=user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + await user.delete() + return {"message": "User deleted successfully"} \ No newline at end of file diff --git a/static/js/api.js b/static/js/api.js index fcd688d..c65792a 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -48,8 +48,17 @@ class APIClient { } if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Unknown error' })); - throw new Error(error.detail || 'Request failed'); + let errorData; + try { + errorData = await response.json(); + } catch (e) { + errorData = { message: 'Unknown error' }; + } + const errorMessage = errorData.detail || errorData.message || 'Request failed'; + document.dispatchEvent(new CustomEvent('show-toast', { + detail: { message: errorMessage, type: 'error' } + })); + throw new Error(errorMessage); } if (response.status === 204) { @@ -200,9 +209,118 @@ class APIClient { return this.request('files/photos'); } - getThumbnailUrl(fileId) { + async getThumbnailUrl(fileId) { return `${this.baseURL}files/thumbnail/${fileId}`; } + + async listDeletedFiles() { + return this.request('files/deleted'); + } + + async restoreFile(fileId) { + return this.request(`files/${fileId}/restore`, { + method: 'POST' + }); + } + + async listUsers() { + return this.request('admin/users'); + } + + async createUser(userData) { + return this.request('admin/users', { + method: 'POST', + body: userData + }); + } + + async updateUser(userId, userData) { + return this.request(`admin/users/${userId}`, { + method: 'PUT', + body: userData + }); + } + + async deleteUser(userId) { + return this.request(`admin/users/${userId}`, { + method: 'DELETE' + }); + } + + async starFile(fileId) { + return this.request(`files/${fileId}/star`, { + method: 'POST' + }); + } + + async unstarFile(fileId) { + return this.request(`files/${fileId}/unstar`, { + method: 'POST' + }); + } + + async starFolder(folderId) { + return this.request(`folders/${folderId}/star`, { + method: 'POST' + }); + } + + async unstarFolder(folderId) { + return this.request(`folders/${folderId}/unstar`, { + method: 'POST' + }); + } + + async listStarredFiles() { + return this.request('starred/files'); + } + + async listStarredFolders() { + return this.request('starred/folders'); + } + + async listRecentFiles() { + return this.request('files/recent'); + } + + async listMyShares() { + return this.request('shares/my'); + } + + async updateShare(shareId, shareData) { + return this.request(`shares/${shareId}`, { + method: 'PUT', + body: shareData + }); + } + + async deleteShare(shareId) { + return this.request(`shares/${shareId}`, { + method: 'DELETE' + }); + } + + async batchFileOperations(operation, fileIds, targetFolderId = null) { + const payload = { file_ids: fileIds, operation: operation }; + if (targetFolderId !== null) { + payload.target_folder_id = targetFolderId; + } + return this.request('files/batch', { + method: 'POST', + body: payload + }); + } + + async batchFolderOperations(operation, folderIds, targetFolderId = null) { + const payload = { folder_ids: folderIds, operation: operation }; + if (targetFolderId !== null) { + payload.target_folder_id = targetFolderId; + } + return this.request('folders/batch', { + method: 'POST', + body: payload + }); + } } export const api = new APIClient(); diff --git a/static/js/components/admin-dashboard.js b/static/js/components/admin-dashboard.js new file mode 100644 index 0000000..092b8b3 --- /dev/null +++ b/static/js/components/admin-dashboard.js @@ -0,0 +1,174 @@ +import { api } from '../api.js'; + +export class AdminDashboard extends HTMLElement { + constructor() { + super(); + this.users = []; + } + + async connectedCallback() { + await this.loadUsers(); + } + + async loadUsers() { + try { + this.users = await api.listUsers(); + this.render(); + } catch (error) { + console.error('Failed to load users:', error); + this.innerHTML = '

Failed to load users. Do you have admin privileges?

'; + } + } + + render() { + this.innerHTML = ` +
+

Admin Dashboard

+ +
+

User Management

+ +
+ ${this.users.map(user => this.renderUser(user)).join('')} +
+
+ + +
+ `; + + this.querySelector('#createUserButton').addEventListener('click', () => this._showUserModal()); + this.querySelector('.user-list').addEventListener('click', this._handleUserAction.bind(this)); + this.querySelector('.close-button').addEventListener('click', () => this.querySelector('#userModal').style.display = 'none'); + this.querySelector('#userForm').addEventListener('submit', this._handleUserFormSubmit.bind(this)); + } + + _handleUserAction(event) { + const target = event.target; + const userItem = target.closest('.user-item'); + if (!userItem) return; + + const userId = userItem.dataset.userId; // Assuming user ID will be stored in data-userId attribute + + if (target.classList.contains('button-danger')) { + this._deleteUser(userId); + } else if (target.classList.contains('button')) { // Edit button + this._showUserModal(userId); + } + } + + _showUserModal(userId = null) { + const modal = this.querySelector('#userModal'); + const form = this.querySelector('#userForm'); + form.reset(); // Clear previous form data + + if (userId) { + const user = this.users.find(u => u.id == userId); + if (user) { + this.querySelector('#userId').value = user.id; + this.querySelector('#username').value = user.username; + this.querySelector('#email').value = user.email; + this.querySelector('#isSuperuser').checked = user.is_superuser; + this.querySelector('#isActive').checked = user.is_active; + this.querySelector('#is2faEnabled').checked = user.is_2fa_enabled; + this.querySelector('#storageQuotaBytes').value = user.storage_quota_bytes; + this.querySelector('#planType').value = user.plan_type; + this.querySelector('h3').textContent = 'Edit User'; + } + } else { + this.querySelector('#userId').value = ''; + this.querySelector('h3').textContent = 'Create New User'; + } + modal.style.display = 'block'; + } + + async _handleUserFormSubmit(event) { + event.preventDefault(); + const userId = this.querySelector('#userId').value; + const userData = { + username: this.querySelector('#username').value, + email: this.querySelector('#email').value, + password: this.querySelector('#password').value || undefined, // Only send if not empty + is_superuser: this.querySelector('#isSuperuser').checked, + is_active: this.querySelector('#isActive').checked, + is_2fa_enabled: this.querySelector('#is2faEnabled').checked, + storage_quota_bytes: parseInt(this.querySelector('#storageQuotaBytes').value), + plan_type: this.querySelector('#planType').value, + }; + + try { + if (userId) { + await api.updateUser(userId, userData); + } else { + await api.createUser(userData); + } + this.querySelector('#userModal').style.display = 'none'; + await this.loadUsers(); // Refresh the list + } catch (error) { + console.error('Failed to save user:', error); + alert('Failed to save user: ' + error.message); + } + } + + async _deleteUser(userId) { + if (!confirm('Are you sure you want to delete this user?')) { + return; + } + try { + await api.deleteUser(userId); + await this.loadUsers(); // Refresh the list + } catch (error) { + console.error('Failed to delete user:', error); + alert('Failed to delete user: ' + error.message); + } + } + + renderUser(user) { + const formatBytes = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ` +
+ + ${user.username} (${user.email}) - Superuser: ${user.is_superuser ? 'Yes' : 'No'} - 2FA: ${user.is_2fa_enabled ? 'Yes' : 'No'} - Active: ${user.is_active ? 'Yes' : 'No'} - Storage: ${formatBytes(user.storage_quota_bytes)} - Plan: ${user.plan_type} + + +
+ `; + } +} + +customElements.define('admin-dashboard', AdminDashboard);