feat: Add role management UI in admin panel

- Add role dropdown column in users table
- Add /admin/users-api/change-role endpoint
- Sync is_admin flag when role changes
- Auto-create UserCompanyPermissions for EMPLOYEE
- Prevent self-demotion from admin

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 06:48:26 +01:00
parent ae70ad326e
commit a325e1b2e4
2 changed files with 191 additions and 1 deletions

View File

@ -17,7 +17,7 @@ from flask import jsonify, request
from flask_login import current_user, login_required
from werkzeug.security import generate_password_hash
from database import SessionLocal, User, Company
from database import SessionLocal, User, Company, SystemRole, CompanyRole, UserCompanyPermissions
import gemini_service
from . import bp
@ -305,3 +305,102 @@ def admin_users_bulk_create():
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
@bp.route('/users-api/change-role', methods=['POST'])
@login_required
def admin_users_change_role():
"""Change user's system role."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
data = request.get_json() or {}
user_id = data.get('user_id')
new_role = data.get('role')
if not user_id or not new_role:
return jsonify({'success': False, 'error': 'Brak wymaganych danych'}), 400
# Validate role
valid_roles = ['UNAFFILIATED', 'MEMBER', 'EMPLOYEE', 'MANAGER', 'OFFICE_MANAGER', 'ADMIN']
if new_role not in valid_roles:
return jsonify({'success': False, 'error': f'Nieprawidłowa rola: {new_role}'}), 400
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
return jsonify({'success': False, 'error': 'Użytkownik nie znaleziony'}), 404
# Prevent self-demotion from admin
if user.id == current_user.id and new_role != 'ADMIN':
return jsonify({'success': False, 'error': 'Nie możesz odebrać sobie uprawnień administratora'}), 400
old_role = user.role
user.role = new_role
# Sync is_admin flag
user.is_admin = (new_role == 'ADMIN')
# Update company_role based on new role
if new_role in ['MANAGER']:
user.company_role = 'MANAGER'
elif new_role in ['EMPLOYEE']:
user.company_role = 'EMPLOYEE'
elif new_role in ['UNAFFILIATED', 'MEMBER']:
user.company_role = 'NONE'
# OFFICE_MANAGER and ADMIN keep their company_role unchanged
# Create default permissions for EMPLOYEE if they have a company
if new_role == 'EMPLOYEE' and user.company_id:
existing_perms = db.query(UserCompanyPermissions).filter_by(
user_id=user.id,
company_id=user.company_id
).first()
if not existing_perms:
perms = UserCompanyPermissions(
user_id=user.id,
company_id=user.company_id,
granted_by_id=current_user.id
)
db.add(perms)
db.commit()
logger.info(f"Admin {current_user.email} changed role for user {user.email}: {old_role} -> {new_role}")
return jsonify({
'success': True,
'message': f'Rola zmieniona na {new_role}',
'user_id': user.id,
'old_role': old_role,
'new_role': new_role
})
except Exception as e:
db.rollback()
logger.error(f"Error changing user role: {e}")
return jsonify({'success': False, 'error': f'Błąd: {str(e)}'}), 500
finally:
db.close()
@bp.route('/users-api/roles', methods=['GET'])
@login_required
def admin_users_get_roles():
"""Get list of available roles for dropdown."""
if not current_user.is_admin:
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
roles = [
{'value': 'UNAFFILIATED', 'label': 'Niezrzeszony', 'description': 'Firma spoza Izby'},
{'value': 'MEMBER', 'label': 'Członek', 'description': 'Członek Norda bez firmy'},
{'value': 'EMPLOYEE', 'label': 'Pracownik', 'description': 'Pracownik firmy członkowskiej'},
{'value': 'MANAGER', 'label': 'Kadra Zarządzająca', 'description': 'Pełna kontrola firmy'},
{'value': 'OFFICE_MANAGER', 'label': 'Kierownik Biura', 'description': 'Panel admina bez użytkowników'},
{'value': 'ADMIN', 'label': 'Administrator', 'description': 'Pełne prawa'},
]
return jsonify({'success': True, 'roles': roles})

View File

@ -181,6 +181,29 @@
color: #1D4ED8;
}
.role-select {
padding: 4px 8px;
font-size: var(--font-size-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
min-width: 140px;
}
.role-select:hover:not(:disabled) {
border-color: var(--primary);
}
.role-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.role-select option {
padding: 4px;
}
.badge-verified {
background: #D1FAE5;
color: #065F46;
@ -1085,6 +1108,7 @@
<th>ID</th>
<th>Użytkownik</th>
<th>Firma</th>
<th>Rola</th>
<th>Utworzono</th>
<th>Status</th>
<th>Akcje</th>
@ -1112,6 +1136,19 @@
<span style="color: var(--text-secondary);">-</span>
{% endif %}
</td>
<td>
<select class="role-select"
data-user-id="{{ user.id }}"
onchange="changeUserRole({{ user.id }}, this.value)"
{% if user.id == current_user.id %}disabled title="Nie możesz zmienić swojej roli"{% endif %}>
<option value="UNAFFILIATED" {% if user.role == 'UNAFFILIATED' %}selected{% endif %}>Niezrzeszony</option>
<option value="MEMBER" {% if user.role == 'MEMBER' %}selected{% endif %}>Członek</option>
<option value="EMPLOYEE" {% if user.role == 'EMPLOYEE' %}selected{% endif %}>Pracownik</option>
<option value="MANAGER" {% if user.role == 'MANAGER' %}selected{% endif %}>Kadra Zarządzająca</option>
<option value="OFFICE_MANAGER" {% if user.role == 'OFFICE_MANAGER' %}selected{% endif %}>Kierownik Biura</option>
<option value="ADMIN" {% if user.role == 'ADMIN' %}selected{% endif %}>Administrator</option>
</select>
</td>
<td style="font-size: var(--font-size-sm); color: var(--text-secondary);">
{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}
</td>
@ -1701,6 +1738,60 @@ Lub format CSV, Excel, lista emaili..."></textarea>
});
});
async function changeUserRole(userId, newRole) {
const select = document.querySelector(`select.role-select[data-user-id="${userId}"]`);
const originalValue = select.dataset.originalValue || select.value;
try {
const response = await fetch('/admin/users-api/change-role', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
user_id: userId,
role: newRole
})
});
const data = await response.json();
if (data.success) {
select.dataset.originalValue = newRole;
showMessage(`Rola zmieniona na: ${getRoleLabel(newRole)}`, 'success');
// Update admin toggle button state if role changed to/from ADMIN
const row = select.closest('tr');
const adminBtn = row.querySelector('.admin-toggle');
if (adminBtn) {
if (newRole === 'ADMIN') {
adminBtn.classList.add('active');
} else {
adminBtn.classList.remove('active');
}
}
} else {
select.value = originalValue;
showMessage(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
select.value = originalValue;
showMessage('Błąd połączenia', 'error');
}
}
function getRoleLabel(role) {
const labels = {
'UNAFFILIATED': 'Niezrzeszony',
'MEMBER': 'Członek',
'EMPLOYEE': 'Pracownik',
'MANAGER': 'Kadra Zarządzająca',
'OFFICE_MANAGER': 'Kierownik Biura',
'ADMIN': 'Administrator'
};
return labels[role] || role;
}
async function toggleAdmin(userId) {
try {
const response = await fetch(`/admin/users/${userId}/toggle-admin`, {