Rôles, tableau de bord et tests unitaires

This commit is contained in:
Yasser Boulayoune 2026-04-07 16:26:00 +02:00
parent f508a2fcb5
commit 1799035236
51 changed files with 4350 additions and 0 deletions

View file

@ -0,0 +1,322 @@
{% extends 'base.html.twig' %}
{% block title %}Edit {{ user.pseudo }} - ServerControl{% endblock %}
{% block body %}
<div class="row g-4 justify-content-center">
<div class="col-lg-4 col-md-5">
<a href="{{ path('app_admin_users') }}" class="text-muted text-decoration-none small">
<i class="bi bi-arrow-left me-1"></i>Back to users
</a>
<div class="card mt-3">
<div class="card-header py-3 d-flex align-items-center gap-3">
<div class="bg-secondary rounded-circle d-flex align-items-center justify-content-center"
style="width:44px;height:44px;flex-shrink:0;">
<i class="bi bi-person-fill text-white fs-5"></i>
</div>
<div class="overflow-hidden">
<div class="fw-bold text-truncate">
{{ user.pseudo }}
{% if isSelf %}
<span class="badge bg-secondary ms-1" style="font-size:.65rem;">Vous</span>
{% endif %}
</div>
<div class="text-muted small text-truncate">{{ user.email }}</div>
</div>
<span class="badge ms-auto {{ user.isActive ? 'bg-success' : 'bg-secondary' }}">
{{ user.isActive ? 'Active' : 'Inactive' }}
</span>
</div>
{# ── Onglets ── #}
<div class="card-body p-0">
<ul class="nav nav-tabs px-3 pt-3" id="userEditTabs">
<li class="nav-item">
<button class="nav-link {% if activeTab == 'roles' %}active{% endif %}"
data-bs-toggle="tab" data-bs-target="#tab-roles" type="button">
<i class="bi bi-shield me-1"></i>Rôles
</button>
</li>
<li class="nav-item">
<button class="nav-link {% if activeTab == 'email' %}active{% endif %}"
data-bs-toggle="tab" data-bs-target="#tab-email" type="button">
<i class="bi bi-envelope me-1"></i>Email
</button>
</li>
<li class="nav-item">
<button class="nav-link {% if activeTab == 'password' %}active{% endif %}"
data-bs-toggle="tab" data-bs-target="#tab-password" type="button">
<i class="bi bi-key me-1"></i>Mot de passe
</button>
</li>
</ul>
<div class="tab-content p-4">
{# ── Onglet 1 : Rôles & Statut ── #}
<div class="tab-pane fade {% if activeTab == 'roles' %}show active{% endif %}" id="tab-roles">
{% if isSelf %}
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
Vous ne pouvez pas modifier vos propres rôles et statut.
</div>
{% endif %}
{{ form_start(form) }}
<div class="mb-3">
<label class="form-label fw-semibold small">Roles</label>
<div class="d-flex flex-wrap gap-2 p-3 rounded border {{ isSelf ? 'opacity-50' : '' }}">
{% for child in form.roles %}
<div class="form-check form-check-inline m-0">
{{ form_widget(child, {attr: {
class: 'form-check-input',
disabled: isSelf ? 'disabled' : false
}}) }}
{{ form_label(child, null, {label_attr: {class: 'form-check-label'}}) }}
</div>
{% endfor %}
</div>
{{ form_errors(form.roles) }}
</div>
<div class="mb-4 {{ isSelf ? 'opacity-50' : '' }}">
<div class="form-check form-switch">
{{ form_widget(form.isActive, {attr: {
class: 'form-check-input',
role: 'switch',
id: 'isActiveSwitch',
disabled: isSelf ? 'disabled' : false
}}) }}
<label class="form-check-label fw-semibold" for="isActiveSwitch">
Compte actif
</label>
</div>
</div>
{% if not isSelf %}
<div class="d-flex gap-2 pt-2 border-top">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-floppy me-1"></i>Enregistrer
</button>
</div>
{% endif %}
{{ form_end(form) }}
</div>
{# ── Onglet 2 : Email ── #}
<div class="tab-pane fade {% if activeTab == 'email' %}show active{% endif %}" id="tab-email">
<p class="text-muted small mb-3">
Modifier l'adresse email du compte. L'utilisateur devra utiliser
la nouvelle adresse pour se connecter.
</p>
{{ form_start(emailForm) }}
<div class="mb-3">
{{ form_label(emailForm.email, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
{{ form_widget(emailForm.email, {attr: {class: 'form-control'}}) }}
{{ form_errors(emailForm.email) }}
</div>
<div class="pt-2 border-top">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-floppy me-1"></i>Changer l'email
</button>
</div>
{{ form_end(emailForm) }}
</div>
{# ── Onglet 3 : Mot de passe ── #}
<div class="tab-pane fade {% if activeTab == 'password' %}show active{% endif %}" id="tab-password">
<p class="text-muted small mb-3">
Definir un nouveau mot de passe pour ce compte.
{% if isSelf %}L'ancien mot de passe sera remplacé.{% endif %}
</p>
{{ form_start(passwordForm) }}
<div class="mb-3">
{{ form_label(passwordForm.newPassword.first, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
{{ form_widget(passwordForm.newPassword.first, {attr: {class: 'form-control'}}) }}
{{ form_errors(passwordForm.newPassword.first) }}
</div>
<div class="mb-4">
{{ form_label(passwordForm.newPassword.second, null, {label_attr: {class: 'form-label fw-semibold small'}}) }}
{{ form_widget(passwordForm.newPassword.second, {attr: {class: 'form-control'}}) }}
{{ form_errors(passwordForm.newPassword.second) }}
{{ form_errors(passwordForm.newPassword) }}
</div>
<div class="pt-2 border-top">
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-key me-1"></i>Changer le mot de passe
</button>
</div>
{{ form_end(passwordForm) }}
</div>
</div>{# /tab-content #}
</div>{# /card-body #}
{% if accesses|length > 0 %}
<div class="card-footer">
<div class="text-muted small mb-1 fw-semibold">
<i class="bi bi-hdd-stack me-1"></i>Assigned servers ({{ accesses|length }})
</div>
<div class="d-flex flex-wrap gap-1">
{% for access in accesses %}
<span class="badge bg-primary">{{ access.server.name }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<div class="col-lg-6 col-md-7">
<div style="margin-top: 2rem;">
{% if accesses|length > 0 %}
<div class="card mb-4">
<div class="card-header">
<strong><i class="bi bi-check-circle me-2 text-success"></i>Current access ({{ accesses|length }})</strong>
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead>
<tr>
<th>Server</th>
<th>IP</th>
<th>SSH User</th>
<th></th>
</tr>
</thead>
<tbody>
{% for access in accesses %}
<tr>
<td class="fw-semibold">{{ access.server.name }}</td>
<td><code class="small">{{ access.server.ipAddress }}</code></td>
<td><code class="small">{{ access.sshUser }}</code></td>
<td>
<form method="post"
action="{{ path('app_admin_revoke_access', {userId: user.id, accessId: access.id}) }}"
onsubmit="return confirm('Revoke access to {{ access.server.name }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('revoke_access_' ~ access.id) }}">
<button class="btn btn-sm btn-outline-danger" title="Revoke">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% set unassignedServers = allServers|filter(s => s.id not in assignedIds) %}
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<strong><i class="bi bi-plus-circle me-2 text-primary"></i>Grant server access</strong>
<span class="badge bg-secondary">{{ unassignedServers|length }} available</span>
</div>
<div class="card-body pb-0">
{% if unassignedServers|length == 0 %}
<p class="text-muted small pb-3 mb-0">
<i class="bi bi-check-all me-1"></i>Tout les serveurs sont déjà assignés a cet utilisateur.
</p>
{% else %}
<div class="server-search-wrap mb-3">
<i class="bi bi-search"></i>
<input type="text" id="server-search" class="form-control form-control-sm"
placeholder="Search servers by name or IP…">
</div>
<div id="server-list">
{% for srv in unassignedServers %}
<div class="server-item border rounded mb-2 overflow-hidden"
data-name="{{ srv.name|lower }}" data-ip="{{ srv.ipAddress }}">
<div class="d-flex align-items-center px-3 py-2 gap-2"
style="cursor:pointer" onclick="toggleServer(this)">
<i class="bi bi-hdd text-primary"></i>
<div class="flex-grow-1">
<span class="fw-semibold">{{ srv.name }}</span>
<code class="text-muted small ms-2">{{ srv.ipAddress }}</code>
</div>
<span class="badge {{ srv.lastStatus == 'online' ? 'bg-success' : (srv.lastStatus == 'offline' ? 'bg-danger' : 'bg-secondary') }} small">
{{ srv.lastStatus }}
</span>
<i class="bi bi-chevron-down text-muted small toggle-icon"></i>
</div>
<form class="grant-form" method="post"
action="{{ path('app_admin_grant_access', {id: user.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('grant_access_' ~ user.id) }}">
<input type="hidden" name="server_id" value="{{ srv.id }}">
<div style="min-width:140px;flex:1">
<label class="form-label">SSH User</label>
<input class="form-control form-control-sm" name="ssh_user"
placeholder="e.g. deploy" required>
</div>
<div style="min-width:160px;flex:1">
<label class="form-label">SSH Password</label>
<input class="form-control form-control-sm" name="ssh_password"
type="password" placeholder="••••••••" required>
</div>
<div class="d-flex gap-1 align-self-end">
<button type="submit" class="btn btn-primary btn-sm px-3">
<i class="bi bi-check-lg me-1"></i>Grant
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="toggleServer(this.closest('.server-item').querySelector('[onclick]'))">
Cancel
</button>
</div>
</form>
</div>
{% endfor %}
</div>
<p id="no-results" class="text-muted small d-none px-1 pb-3">
<i class="bi bi-search me-1"></i>Aucun serveur ne correspond a votre recheche.
</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
<script>
function toggleServer(trigger) {
const item = trigger.closest('.server-item');
const isOpen = item.classList.contains('open');
document.querySelectorAll('.server-item.open').forEach(el => {
el.classList.remove('open');
el.querySelector('.toggle-icon').style.transform = '';
});
if (!isOpen) {
item.classList.add('open');
item.querySelector('.toggle-icon').style.transform = 'rotate(180deg)';
setTimeout(() => item.querySelector('input[name="ssh_user"]')?.focus(), 50);
}
}
document.getElementById('server-search')?.addEventListener('input', function () {
const q = this.value.toLowerCase().trim();
let visible = 0;
document.querySelectorAll('.server-item').forEach(item => {
const match = !q || item.dataset.name.includes(q) || item.dataset.ip.includes(q);
item.classList.toggle('hidden', !match);
if (match) visible++;
});
document.getElementById('no-results').classList.toggle('d-none', visible > 0);
});
</script>
{% endblock %}