v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions

Admin backoffice — UI redesign :
 - Pages versions.php + licenses.php : remplace les <details> inline qui
   débordaient horizontalement par UN bouton « ✎ Modifier » par row qui
   ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes,
   BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel,
   BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets.
 - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback
   visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la
   clé aux clients.
 - Overlay « hashing en cours » plein écran sur tous les boutons de hash
   (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé
   par scope (bulk vs single).
 - Date de release passée de datetime-local à date (l'heure n'a pas de
   sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an
   pour min_license_date (= license standard couvre les releases sur 1 an).

Emails de notification release :
 - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV)
 - Onglet « Contacts » sur la modal licenses pour saisir les emails par
   license (parsing tolérant : CSV, ligne par ligne, point-virgule)
 - Bouton « ✉ Notifier » par version : POST notify_release filtre les
   licenses éligibles (channel match + min_license_date + can_see_betas
   pour BÊTA) et envoie un email HTML à chaque contact (dédup global)
 - Template email table-based + bgcolor (compat Outlook/Word engine),
   navy foncé #0F172A, logo Asterion en CID embed (= affichage direct
   sans demande de permission Outlook), bouton download installer
   centré (align="center" + margin auto), release notes en <pre>
 - Mailer.php helper : parse emails, multipart/related avec attachments
   inline, fallback execCommand pour clipboard

4-digit version support (X.Y.Z.B) :
 - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent =
   0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve
   le format d'origine au round-trip.
 - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît
   « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque
 - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php,
   Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit
 - Use case : dev/test iterations cohabitant avec leur release stable

Bugs fixes :
 - migrate.php : strip ligne par ligne les commentaires SQL avant le
   check is-empty. Sans ça, le PREMIER chunk d'un fichier migration
   (= header + premier ALTER) commençait par `--` et était silencieusement
   skip → ALTER jamais appliqué. Affectait migrations 003, 004.
 - SignManifest::getOrComputeSha256 ignorait son cache interne quand force
   demandé par le caller, retournant l'ancien hash en 0 ms même après
   re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force.
   Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique.
 - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh
   triggers, avec HEAD probe pour comparer taille serveur vs manifest →
   message d'erreur explicite si ZIP tronqué. Bps display lissé sur une
   fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un
   segment finit / Polly retry. SHA mismatch popup enrichi avec les
   deux SHAs (attendu vs calculé) extraits via regex de l'exception.
 - DownloadUrl.php : signature de l'URL utilisait un template hardcodé
   /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit
   maintenant download.url du manifest et signe le filename réel.

Strings i18n (5 langues) :
 - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch,
   stale manifest, manifest refreshed auto-retry, force fresh menu

Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 17:24:50 +02:00
parent 50da755a92
commit 500f7d12e6
21 changed files with 1674 additions and 264 deletions

View File

@@ -3,11 +3,13 @@ declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin();
@@ -117,13 +119,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
}
elseif ($action === 'toggle_betas') {
// Renommé semantically (toggle → set explicit) sans casser le nom
// de l'action POST pour préserver les éventuels liens externes
// existants. La modal envoie maintenant `enabled=1` si checkbox
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
$stmt->execute([$id]);
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
$row->execute([$id]);
$now = (int)$row->fetchColumn();
$message = $now
$enabled = isset($_POST['enabled']) ? 1 : 0;
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
$message = $enabled
? "License #{$id} : accès aux versions BÊTA activé."
: "License #{$id} : accès aux versions BÊTA désactivé.";
}
@@ -165,6 +168,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
}
}
elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact pour les notifications
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
// virgules, point-virgules — peu importe), on parse + valide via
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
// valide → on stocke NULL (= pas de notif possible pour cette
// license, juste skip silencieux à l'envoi).
$id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? '');
if ($id <= 0) {
throw new Exception('License id invalide.');
}
$parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
->execute([$stored, $id]);
$count = count($parsed);
$message = $count === 0
? "License #{$id} : contacts de notification supprimés."
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
@@ -295,8 +319,21 @@ Layout::header('Licenses', 'licenses');
<td>#<?= $l['id'] ?></td>
<td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
<?php // Clé license affichée en monospace + bouton 1-clic
// pour copier dans le presse-papier. Permet à l'admin
// de la transmettre rapidement à un client sans passer
// par phpMyAdmin. Sensibilité OK : la page admin est
// déjà derrière auth + la clé seule ne suffit pas
// (validation serveur HMAC + machine_id requis). ?>
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
<button type="button" class="copy-btn"
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
title="Copier la clé license"
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
</div>
<?php if (!empty($l['notes'])): ?>
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?>
</td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
@@ -313,119 +350,259 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?>
</td>
<td>
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
<div style="margin-bottom: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué à cette license">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
</div>
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
pour trouver l'option. -->
<form method="post" style="display: inline-block; margin-right: 4px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
type="submit" style="font-size: 11px; padding: 4px 10px;"
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
</button>
</form>
<!-- Channel : modifier via un menu compact -->
<details style="display: inline-block;">
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
✎ Channel
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<select name="channel" style="min-width: 160px;">
<?php foreach ($availableChannels as $value => $label):
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
les éditeurs sont passés dans le modal pour éviter
l'encombrement de la cellule. -->
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<?php if ($hasLock): ?>
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Prolonger</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
<div class="row-actions">
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php
// Modal d'édition (capturé en buffer, flush après </table>).
// Même pattern que versions.php : <dialog> dans <table> est
// foster-parented imprévisiblement → on émet hors de la table.
ob_start();
?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<nav class="modal-tabs">
<button type="button" data-tab="prolong" class="active">Prolonger</button>
<button type="button" data-tab="slots">Slots</button>
<button type="button" data-tab="channel">Channel</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="lock">Lock</button>
<button type="button" data-tab="contacts">Contacts</button>
<button type="button" data-tab="machines">Machines</button>
</nav>
<div class="modal-body">
<!-- TAB : Prolonger -->
<section data-tab="prolong">
<p class="section-intro">
Repousse la date d'expiration de l'entitlement.
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="date" name="new_until" required style="width: 150px;">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Nouvelle date d'expiration</label>
<input type="date" name="new_until" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Prolonger</button>
</div>
</form>
</details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Slots</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
</section>
<!-- TAB : Slots -->
<section data-tab="slots" hidden>
<p class="section-intro">
Nombre maximum de machines pouvant être activées simultanément.
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
Pour réduire en-dessous du nombre actif, libère d'abord les slots
via l'onglet « Machines ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_max_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required
style="width: 70px;"
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Slots max</label>
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
</div>
</form>
</details>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary"
title="Mot de passe qui verrouille la section Settings → Avancés du launcher pour cette license. Laisser vide pour retirer le verrouillage.">
<?= $hasLock ? '🔒 Lock' : 'Lock' ?>
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
</section>
<!-- TAB : Channel -->
<section data-tab="channel" hidden>
<p class="section-intro">
Quel manifest sert cette license ? « default » = manifest public.
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
permettent de servir des versions ciblées (police, firefighter, …).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Channel</label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label):
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Si activé, le client voit en plus des versions stables toutes les
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
elles lui sont invisibles (filtrées côté manifest signé).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>
<input type="checkbox" name="enabled" value="1"
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
Voir les versions BÊTA
</label>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Lock (settings password) -->
<section data-tab="lock" hidden>
<p class="section-intro">
Mot de passe qui verrouille la section Settings → Avancés du
launcher pour cette license. Vide = retirer le verrouillage.
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_settings_lock_password">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe (≥4)' ?>"
minlength="0" maxlength="100"
style="width: 200px;"
autocomplete="new-password">
<button class="btn btn-primary" type="submit">OK</button>
<div class="field">
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
minlength="0" maxlength="100"
autocomplete="new-password">
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
</div>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
</form>
<?php endif; ?>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">Réactiver</button>
</form>
<?php endif; ?>
</td>
</tr>
</section>
<!-- TAB : Contacts (emails de notification release) -->
<section data-tab="contacts" hidden>
<p class="section-intro">
Liste d'adresses email à notifier quand tu publies une nouvelle
version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement.
</p>
<?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails);
?>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6"
placeholder="ops@asterionvr.com&#10;jerome@client.com&#10;technicien@partner.fr"
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
<p class="hint" style="margin: 4px 0 0;">
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
Vider le champ = retirer toutes les notifications.
</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
</div>
</form>
</section>
<!-- TAB : Machines -->
<section data-tab="machines" hidden>
<p class="section-intro">
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
La gestion fine machine par machine se fait via le tableau dépliable
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
</p>
<?php if ($l['machines_count'] > 0): ?>
<div class="field">
<label>Action en bloc</label>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
</form>
</div>
<?php else: ?>
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
<?php
// Sous-row dépliable : liste des machines avec remove individuel.
@@ -472,6 +649,14 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?>
</td>
<td style="text-align:right;">
<?php // Note : pas de "Déplacer vers une autre license" ici —
// ce serait une feature DB-only fragile (le launcher
// ré-activerait automatiquement la machine sur sa
// license d'origine au prochain validate puisque sa
// clé license stockée n'a pas changé). La migration
// d'une machine vers une autre license se fait côté
// launcher : Settings → License → entrer la nouvelle
// clé. ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?>
@@ -500,4 +685,70 @@ Layout::header('Licenses', 'licenses');
</tbody>
</table>
</div>
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<!-- Handler global pour les onglets des modals (délégation sur document). -->
<script>
(function () {
document.addEventListener('click', function (e) {
// 1. Tabs des modals d'édition license
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (btn) {
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
return;
}
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
var copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
var text = copyBtn.dataset.copy || '';
var original = copyBtn.textContent;
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
// Fallback execCommand pour les browsers très anciens.
var copyOk = function () {
copyBtn.textContent = '✓';
copyBtn.style.color = '#16A34A';
setTimeout(function () {
copyBtn.textContent = original;
copyBtn.style.color = '';
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(copyOk, function () {
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
fallbackCopy(text, copyOk);
});
} else {
fallbackCopy(text, copyOk);
}
}
});
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
// browsers sans avoir besoin de la permission Clipboard API.
function fallbackCopy(text, onSuccess) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onSuccess(); }
catch (e) { /* silent */ }
document.body.removeChild(ta);
}
})();
</script>
<?php Layout::footer();