Parcourir la source

Feature: Add user creation and password management to admin panel

Added comprehensive admin capabilities for user management:

**New API Endpoints:**
- POST /api/admin/users - Create new users with full account setup
- PUT /api/admin/users/{user_id}/password - Change any user's password

**Admin UI Enhancements:**
- "Add User" button with modal form for creating users
  - Username, email, display name, password fields
  - Audiobookshelf connection details (URL + API token)
  - Option to make new user an admin
- "Change Password" button for each user in the table
  - Simple modal with password input (min 6 characters)
  - Shows username being updated
- Modal styles with overlays and animations

**Features:**
- Duplicate username/email validation
- Password minimum length validation (6 chars)
- Admin cannot delete or modify their own admin status
- All users can have passwords changed by admin
- Success/error messaging for all operations
- Auto-refresh user list after changes

**Files Changed:**
- app/main.py - Added create_user_by_admin and change_user_password endpoints
- app/templates/admin.html - Added modal forms
- app/static/js/admin.js - Added modal handling and form submission
- app/static/css/style.css - Added modal styles

Admins now have full control over user accounts without needing shell access.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Brad Lance il y a 3 mois
Parent
commit
656ac08c7c
4 fichiers modifiés avec 365 ajouts et 0 suppressions
  1. 103 0
      app/main.py
  2. 96 0
      app/static/css/style.css
  3. 91 0
      app/static/js/admin.js
  4. 75 0
      app/templates/admin.html

+ 103 - 0
app/main.py

@@ -807,6 +807,109 @@ async def delete_user(
     })
 
 
+@app.post("/api/admin/users")
+async def create_user_by_admin(
+    username: str = Form(...),
+    email: str = Form(...),
+    password: str = Form(...),
+    display_name: str = Form(None),
+    abs_url: str = Form(...),
+    abs_api_token: str = Form(...),
+    is_admin: str = Form("false"),
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Create a new user (admin only)."""
+    try:
+        # Check if username exists
+        result = await db.execute(select(User).where(User.username == username))
+        if result.scalar_one_or_none():
+            return JSONResponse(
+                {"status": "error", "message": "Username already exists"},
+                status_code=400
+            )
+
+        # Check if email exists
+        result = await db.execute(select(User).where(User.email == email))
+        if result.scalar_one_or_none():
+            return JSONResponse(
+                {"status": "error", "message": "Email already exists"},
+                status_code=400
+            )
+
+        # Create new user
+        new_user = await create_user(
+            db=db,
+            username=username,
+            email=email,
+            password=password,
+            display_name=display_name,
+            abs_url=abs_url,
+            abs_api_token=abs_api_token
+        )
+
+        # Set admin status if requested
+        if is_admin.lower() == "true":
+            new_user.is_admin = True
+            await db.commit()
+
+        return JSONResponse({
+            "status": "success",
+            "message": f"User '{username}' created successfully"
+        })
+
+    except Exception as e:
+        return JSONResponse(
+            {"status": "error", "message": str(e)},
+            status_code=500
+        )
+
+
+@app.put("/api/admin/users/{user_id}/password")
+async def change_user_password(
+    user_id: int,
+    new_password: str = Form(...),
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Change a user's password (admin only)."""
+    try:
+        # Validate password length
+        if len(new_password) < 6:
+            return JSONResponse(
+                {"status": "error", "message": "Password must be at least 6 characters"},
+                status_code=400
+            )
+
+        # Get user
+        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_404_NOT_FOUND,
+                detail="User not found"
+            )
+
+        # Update password
+        from app.auth import hash_password
+        user.hashed_password = hash_password(new_password)
+        await db.commit()
+
+        return JSONResponse({
+            "status": "success",
+            "message": f"Password changed for user '{user.username}'"
+        })
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        return JSONResponse(
+            {"status": "error", "message": str(e)},
+            status_code=500
+        )
+
+
 @app.get("/health")
 async def health_check():
     """Health check endpoint."""

+ 96 - 0
app/static/css/style.css

@@ -753,3 +753,99 @@ header h1 {
     color: #999;
     font-style: italic;
 }
+
+/* ==================== Modals ==================== */
+
+.modal {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.6);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 1000;
+}
+
+.modal.hidden {
+    display: none;
+}
+
+.modal-content {
+    background: white;
+    border-radius: 12px;
+    padding: 0;
+    width: 90%;
+    max-width: 600px;
+    max-height: 90vh;
+    overflow-y: auto;
+    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+}
+
+.modal-content.modal-small {
+    max-width: 400px;
+}
+
+.modal-header {
+    padding: 20px 25px;
+    border-bottom: 1px solid #e0e0e0;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    border-radius: 12px 12px 0 0;
+}
+
+.modal-header h2 {
+    margin: 0;
+    font-size: 1.5rem;
+}
+
+.btn-close {
+    background: none;
+    border: none;
+    color: white;
+    font-size: 2rem;
+    cursor: pointer;
+    padding: 0;
+    width: 32px;
+    height: 32px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 4px;
+    transition: background-color 0.2s;
+}
+
+.btn-close:hover {
+    background-color: rgba(255, 255, 255, 0.2);
+}
+
+.modal-content form {
+    padding: 25px;
+}
+
+.modal-actions {
+    display: flex;
+    gap: 10px;
+    justify-content: flex-end;
+    margin-top: 20px;
+    padding-top: 20px;
+    border-top: 1px solid #e0e0e0;
+}
+
+.form-section {
+    margin-top: 20px;
+    padding-top: 20px;
+    border-top: 1px solid #e0e0e0;
+}
+
+.form-section h3 {
+    margin-top: 0;
+    margin-bottom: 15px;
+    color: #333;
+    font-size: 1.1rem;
+}

+ 91 - 0
app/static/js/admin.js

@@ -121,6 +121,7 @@ function renderUsers(users) {
                 <td>${createdDate}</td>
                 <td>${lastLogin}</td>
                 <td>
+                    <button class="btn btn-small" onclick="showChangePasswordModal(${user.id}, '${user.username}')">Change Password</button>
                     ${!user.is_admin ? `
                         <button class="btn btn-small btn-secondary" onclick="toggleAdmin(${user.id}, true)">Make Admin</button>
                     ` : ''}
@@ -194,3 +195,93 @@ async function deleteUser(userId, username) {
         showMessage('Error deleting user: ' + error.message, 'error');
     }
 }
+
+// ==================== Add User Modal ====================
+
+function showAddUserModal() {
+    document.getElementById('add-user-modal').classList.remove('hidden');
+    document.getElementById('add-user-form').reset();
+}
+
+function hideAddUserModal() {
+    document.getElementById('add-user-modal').classList.add('hidden');
+    document.getElementById('add-user-form').reset();
+}
+
+async function submitAddUser(event) {
+    event.preventDefault();
+
+    const form = event.target;
+    const formData = new FormData(form);
+
+    try {
+        const response = await fetch('/api/admin/users', {
+            method: 'POST',
+            body: formData
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage(data.message || 'User created successfully', 'success');
+            hideAddUserModal();
+            loadUsers(); // Reload user list
+        } else {
+            showMessage(data.message || 'Failed to create user', 'error');
+        }
+    } catch (error) {
+        showMessage('Error creating user: ' + error.message, 'error');
+    }
+}
+
+// ==================== Change Password Modal ====================
+
+function showChangePasswordModal(userId, username) {
+    document.getElementById('change-password-user-id').value = userId;
+    document.getElementById('change-password-username').textContent = username;
+    document.getElementById('change-password-modal').classList.remove('hidden');
+    document.getElementById('change-password-form').reset();
+    // Re-set the hidden user ID after reset
+    document.getElementById('change-password-user-id').value = userId;
+}
+
+function hideChangePasswordModal() {
+    document.getElementById('change-password-modal').classList.add('hidden');
+    document.getElementById('change-password-form').reset();
+}
+
+async function submitChangePassword(event) {
+    event.preventDefault();
+
+    const form = event.target;
+    const userId = document.getElementById('change-password-user-id').value;
+    const formData = new FormData(form);
+
+    try {
+        const response = await fetch(`/api/admin/users/${userId}/password`, {
+            method: 'PUT',
+            body: formData
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage(data.message || 'Password changed successfully', 'success');
+            hideChangePasswordModal();
+        } else {
+            showMessage(data.message || 'Failed to change password', 'error');
+        }
+    } catch (error) {
+        showMessage('Error changing password: ' + error.message, 'error');
+    }
+}

+ 75 - 0
app/templates/admin.html

@@ -27,6 +27,7 @@
 <section class="section">
     <h2>User Management</h2>
     <div class="section-actions">
+        <button onclick="showAddUserModal()" class="btn btn-primary">Add User</button>
         <button onclick="loadUsers()" class="btn btn-secondary">Refresh Users</button>
     </div>
     <div id="users-loading" class="loading">Loading users...</div>
@@ -49,6 +50,80 @@
         </table>
     </div>
 </section>
+
+<!-- Add User Modal -->
+<div id="add-user-modal" class="modal hidden">
+    <div class="modal-content">
+        <div class="modal-header">
+            <h2>Add New User</h2>
+            <button onclick="hideAddUserModal()" class="btn-close">&times;</button>
+        </div>
+        <form id="add-user-form" onsubmit="submitAddUser(event)">
+            <div class="form-group">
+                <label for="new-username">Username *</label>
+                <input type="text" id="new-username" name="username" required>
+            </div>
+            <div class="form-group">
+                <label for="new-email">Email *</label>
+                <input type="email" id="new-email" name="email" required>
+            </div>
+            <div class="form-group">
+                <label for="new-display-name">Display Name</label>
+                <input type="text" id="new-display-name" name="display_name">
+            </div>
+            <div class="form-group">
+                <label for="new-password">Password *</label>
+                <input type="password" id="new-password" name="password" required minlength="6">
+                <small>Minimum 6 characters</small>
+            </div>
+            <div class="form-section">
+                <h3>Audiobookshelf Connection</h3>
+                <div class="form-group">
+                    <label for="new-abs-url">Audiobookshelf URL *</label>
+                    <input type="url" id="new-abs-url" name="abs_url" required placeholder="https://abs.example.com">
+                </div>
+                <div class="form-group">
+                    <label for="new-abs-token">API Token *</label>
+                    <input type="password" id="new-abs-token" name="abs_api_token" required>
+                    <small>Get from Audiobookshelf Settings > Users > Generate API Token</small>
+                </div>
+            </div>
+            <div class="form-group">
+                <label>
+                    <input type="checkbox" id="new-is-admin" name="is_admin" value="true">
+                    Make this user an administrator
+                </label>
+            </div>
+            <div class="modal-actions">
+                <button type="button" onclick="hideAddUserModal()" class="btn btn-secondary">Cancel</button>
+                <button type="submit" class="btn btn-primary">Create User</button>
+            </div>
+        </form>
+    </div>
+</div>
+
+<!-- Change Password Modal -->
+<div id="change-password-modal" class="modal hidden">
+    <div class="modal-content modal-small">
+        <div class="modal-header">
+            <h2>Change Password</h2>
+            <button onclick="hideChangePasswordModal()" class="btn-close">&times;</button>
+        </div>
+        <form id="change-password-form" onsubmit="submitChangePassword(event)">
+            <input type="hidden" id="change-password-user-id" name="user_id">
+            <p>Changing password for: <strong id="change-password-username"></strong></p>
+            <div class="form-group">
+                <label for="new-user-password">New Password *</label>
+                <input type="password" id="new-user-password" name="new_password" required minlength="6">
+                <small>Minimum 6 characters</small>
+            </div>
+            <div class="modal-actions">
+                <button type="button" onclick="hideChangePasswordModal()" class="btn btn-secondary">Cancel</button>
+                <button type="submit" class="btn btn-primary">Change Password</button>
+            </div>
+        </form>
+    </div>
+</div>
 {% endblock %}
 
 {% block extra_scripts %}