322 lines
17 KiB
Twig
322 lines
17 KiB
Twig
{% 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 %}
|