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:
@@ -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 jerome@client.com 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();
|
||||
|
||||
Reference in New Issue
Block a user