feat(users): allow role assignment for Azure AD and LDAP users

- Backend: add admin-only guard + role validation to PUT /users/{id}
- Backend: prevent admins from changing their own role
- Frontend: role toggle button (person-check / person-dash) per user row
- Frontend: admin badge green, viewer badge secondary, ldap badge blue
- i18n: add makeAdmin / makeViewer translations (de + en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sascha Lustenberger | techlan gmbh
2026-02-24 20:27:54 +01:00
parent 8103fffcb8
commit 796824c400
4 changed files with 41 additions and 3 deletions

View File

@@ -70,12 +70,31 @@ async def update_user(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update an existing user (email, is_active, role).""" """Update an existing user (email, is_active, role). Admin only."""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can update users.",
)
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found.")
update_data = payload.model_dump(exclude_none=True) update_data = payload.model_dump(exclude_none=True)
if "role" in update_data:
if update_data["role"] not in ("admin", "viewer"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'viewer'.",
)
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot change your own role.",
)
for field, value in update_data.items(): for field, value in update_data.items():
if hasattr(user, field): if hasattr(user, field):
setattr(user, field, value) setattr(user, field, value)

View File

@@ -1368,8 +1368,8 @@ async function loadUsers() {
<td>${u.id}</td> <td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td> <td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.email || '-')}</td> <td>${esc(u.email || '-')}</td>
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td> <td><span class="badge bg-${u.role === 'admin' ? 'success' : 'secondary'}">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td> <td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : u.auth_provider === 'ldap' ? 'info' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</td> <td>${langDisplay}</td>
<td>${mfaDisplay}</td> <td>${mfaDisplay}</td>
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td> <td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
@@ -1381,6 +1381,11 @@ async function loadUsers() {
} }
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''} ${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''} ${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
${currentUser && currentUser.role === 'admin' && u.id !== currentUser.id
? (u.role === 'admin'
? `<button class="btn btn-outline-secondary" title="${t('settings.makeViewer')}" onclick="toggleUserRole(${u.id}, 'admin')"><i class="bi bi-person-dash"></i></button>`
: `<button class="btn btn-outline-success" title="${t('settings.makeAdmin')}" onclick="toggleUserRole(${u.id}, 'viewer')"><i class="bi bi-person-check"></i></button>`)
: ''}
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button> <button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
</div> </div>
</td> </td>
@@ -1440,6 +1445,16 @@ async function toggleUserActive(id, active) {
} }
} }
async function toggleUserRole(id, currentRole) {
const newRole = currentRole === 'admin' ? 'viewer' : 'admin';
try {
await api('PUT', `/users/${id}`, { role: newRole });
loadUsers();
} catch (err) {
showSettingsAlert('danger', t('errors.updateFailed', { error: err.message }));
}
}
async function resetUserPassword(id, username) { async function resetUserPassword(id, username) {
if (!confirm(t('messages.confirmResetPassword', { username }))) return; if (!confirm(t('messages.confirmResetPassword', { username }))) return;
try { try {

View File

@@ -170,6 +170,8 @@
"saveBranding": "Branding speichern", "saveBranding": "Branding speichern",
"userManagement": "Benutzerverwaltung", "userManagement": "Benutzerverwaltung",
"newUser": "Neuer Benutzer", "newUser": "Neuer Benutzer",
"makeAdmin": "Zum Admin befördern",
"makeViewer": "Zum Viewer degradieren",
"thId": "ID", "thId": "ID",
"thUsername": "Benutzername", "thUsername": "Benutzername",
"thEmail": "E-Mail", "thEmail": "E-Mail",

View File

@@ -191,6 +191,8 @@
"saveBranding": "Save Branding", "saveBranding": "Save Branding",
"userManagement": "User Management", "userManagement": "User Management",
"newUser": "New User", "newUser": "New User",
"makeAdmin": "Promote to admin",
"makeViewer": "Demote to viewer",
"thId": "ID", "thId": "ID",
"thUsername": "Username", "thUsername": "Username",
"thEmail": "Email", "thEmail": "Email",