IT Assets: add role-based auth system and portal user sync

Auth:
- Fix local login (was redirecting to portal; now authenticates AdminUser directly)
- Portal SSO still takes priority in production via nginx headers

Role system (admin | editor | readonly):
- New app/utils/decorators.py with editor_required and admin_required decorators
- All write routes protected with editor_required (create/edit/delete/import/mask)
- Settings user management protected with admin_required
- Sidebar hides write-only links for readonly users
- Dashboard quick actions and list page buttons hidden for readonly

Settings page:
- Role colour badges (admin=red, editor=blue, readonly=grey)
- Inline role changer per user (dropdown auto-submit)
- Reset password modal per user
- Delete user button with confirmation
- Add user form includes role selector with legend

Portal user sync:
- New /internal/sync-user endpoint receives user pre-creation from portal
- INTERNAL_SYNC_SECRET added to config
- portal/config.py: added internal_url for itassets app so _sync_user_to_app works
This commit is contained in:
ske087
2026-07-08 21:35:16 +03:00
parent 6034a62b08
commit 7d24e7f527
17 changed files with 320 additions and 29 deletions
@@ -11,50 +11,79 @@
</div>
<div class="row g-4">
<!-- Admin users -->
<!-- App Users -->
<div class="col-md-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-person-gear me-2 text-primary"></i>Admin Users
<i class="bi bi-person-gear me-2 text-primary"></i>Application Users
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th><th></th></tr>
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th>
{% if current_user.is_admin %}<th></th>{% endif %}
</tr>
</thead>
<tbody>
{% for a in admins %}
<tr>
<td><strong>{{ a.username }}</strong></td>
<td><strong>{{ a.username }}</strong>{% if a.id == current_user.id %} <span class="badge bg-light text-secondary">you</span>{% endif %}</td>
<td>{{ a.full_name or '—' }}</td>
<td>{{ a.email }}</td>
<td><span class="badge bg-secondary">{{ a.role }}</span></td>
<td>
<span class="badge {% if a.role == 'admin' %}bg-danger{% elif a.role == 'editor' %}bg-primary{% else %}bg-secondary{% endif %}">
{{ a.role }}
</span>
</td>
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
{% if a.is_active %}<span class="badge bg-success">Active</span>
{% else %}<span class="badge bg-secondary">Inactive</span>{% endif %}
</td>
<td>
{% if current_user.is_admin %}
<td class="text-end">
{% if a.id != current_user.id %}
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
{{ 'Deactivate' if a.is_active else 'Activate' }}
<div class="d-flex gap-1 justify-content-end flex-wrap">
<!-- Toggle active -->
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
{{ 'Deactivate' if a.is_active else 'Activate' }}
</button>
</form>
<!-- Change role -->
<form method="POST" action="{{ url_for('settings.change_role', admin_id=a.id) }}" class="d-inline">
<select name="role" class="form-select form-select-sm d-inline-block w-auto py-0" onchange="this.form.submit()">
<option value="admin" {% if a.role == 'admin' %}selected{% endif %}>admin</option>
<option value="editor" {% if a.role == 'editor' %}selected{% endif %}>editor</option>
<option value="readonly" {% if a.role == 'readonly' %}selected{% endif %}>readonly</option>
</select>
</form>
<!-- Reset password -->
<button type="button" class="btn btn-xs btn-sm btn-outline-secondary py-0 px-2"
data-bs-toggle="modal" data-bs-target="#pwModal{{ a.id }}">
<i class="bi bi-key"></i>
</button>
</form>
<!-- Delete -->
<form method="POST" action="{{ url_for('settings.delete_admin', admin_id=a.id) }}" class="d-inline"
onsubmit="return confirm('Delete user {{ a.username }}? This cannot be undone.')">
<button type="submit" class="btn btn-xs btn-sm btn-outline-danger py-0 px-2">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Add admin form -->
<!-- Add user form (admin only) -->
{% if current_user.is_admin %}
<div class="card-footer bg-white">
<h6 class="fw-semibold mb-3 mt-1">Add Admin User</h6>
<h6 class="fw-semibold mb-3 mt-1"><i class="bi bi-person-plus me-1"></i>Add User</h6>
<form method="POST" action="{{ url_for('settings.create_admin') }}">
<div class="row g-2">
<div class="col-md-3">
@@ -69,12 +98,25 @@
<div class="col-md-2">
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-sm btn-primary w-100">Add</button>
<div class="col-md-2">
<select name="role" class="form-select form-select-sm">
<option value="readonly">readonly</option>
<option value="editor">editor</option>
<option value="admin">admin</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</div>
</div>
<small class="text-muted mt-1 d-block">
<strong>readonly</strong> view only &nbsp;|&nbsp;
<strong>editor</strong> create &amp; edit data &nbsp;|&nbsp;
<strong>admin</strong> full access including user management
</small>
</form>
</div>
{% endif %}
</div>
</div>
@@ -100,9 +142,11 @@
</tbody>
</table>
<div class="mt-3">
{% if current_user.is_editor %}
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
</a>
{% endif %}
</div>
</div>
</div>
@@ -123,4 +167,31 @@
</div>
</div>
</div>
<!-- Password reset modals (one per user, admin only) -->
{% if current_user.is_admin %}
{% for a in admins %}
{% if a.id != current_user.id %}
<div class="modal fade" id="pwModal{{ a.id }}" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<form method="POST" action="{{ url_for('settings.reset_password', admin_id=a.id) }}">
<div class="modal-header">
<h6 class="modal-title">Reset password — {{ a.username }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="password" name="new_password" class="form-control" placeholder="New password (min 8 chars)" required minlength="8">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-sm btn-primary">Reset</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}