Files
PS_Launcher/server/admin/licenses.php
j.foucher 500f7d12e6 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>
2026-05-28 17:24:50 +02:00

755 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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();
$config = require __DIR__ . '/../api/config.php';
$db = Db::get($config);
$message = null; $messageType = 'success';
$newKey = null;
function generateLicenseKey(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$groups = [];
for ($g = 0; $g < 4; $g++) {
$s = '';
for ($i = 0; $i < 4; $i++) $s .= $alphabet[random_int(0, strlen($alphabet) - 1)];
$groups[] = $s;
}
return 'PRSRV-' . implode('-', $groups);
}
/**
* Liste les channels disponibles depuis le registre channels.json. Le channel
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
* « pas de tag, voit que default »).
*/
function listAvailableChannels(): array
{
$manifestDir = dirname(__DIR__) . '/manifest';
$registry = Channels::load($manifestDir);
$out = ['' => '(aucun — voit uniquement « default »)'];
foreach ($registry['channels'] as $c) {
$name = (string)($c['name'] ?? '');
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
$label = $c['label'] ?? $name;
$out[$name] = "{$name}{$label}";
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
try {
if ($action === 'create') {
$owner = trim($_POST['owner'] ?? '');
$until = trim($_POST['until'] ?? '');
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
$notes = trim($_POST['notes'] ?? '') ?: null;
// Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null;
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
}
$canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
}
for ($attempts = 0; $attempts < 5; $attempts++) {
$newKey = generateLicenseKey();
try {
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
break;
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'Duplicate')) { continue; }
throw $e;
}
}
}
elseif ($action === 'revoke') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('UPDATE licenses SET revoked_at = NOW() WHERE id = ?')->execute([$id]);
$message = "License #{$id} révoquée.";
}
elseif ($action === 'unrevoke') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('UPDATE licenses SET revoked_at = NULL WHERE id = ?')->execute([$id]);
$message = "License #{$id} réactivée.";
}
elseif ($action === 'extend') {
$id = (int)($_POST['id'] ?? 0);
$newUntil = trim($_POST['new_until'] ?? '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $newUntil)) {
throw new Exception('Date invalide.');
}
$db->prepare('UPDATE licenses SET download_entitlement_until = ? WHERE id = ?')
->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
}
elseif ($action === 'set_channel') {
$id = (int)($_POST['id'] ?? 0);
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null; // default manifest
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide.');
}
$db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
$label = $channel ?? 'default';
$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);
$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é.";
}
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
throw new Exception('Nombre de machines invalide (1100).');
}
// Refus de descendre sous le nombre de machines déjà activées —
// sinon état incohérent (machines_count > max_machines). L'admin doit
// d'abord libérer des slots via "Libérer" individuel ou "Libérer toutes".
$stmt = $db->prepare('SELECT COUNT(*) FROM license_machines WHERE license_id = ?');
$stmt->execute([$id]);
$current = (int)$stmt->fetchColumn();
if ($newMax < $current) {
throw new Exception("Impossible : {$current} machines sont déjà actives. Libère des slots d'abord ou choisis ≥ {$current}.");
}
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
}
elseif ($action === 'set_settings_lock_password') {
$id = (int)($_POST['id'] ?? 0);
$pwd = (string)($_POST['settings_lock_password'] ?? '');
if ($id <= 0) {
throw new Exception('License id invalide.');
}
if ($pwd === '') {
// Vide = retire le verrouillage pour cette license
$db->prepare('UPDATE licenses SET settings_lock_password_hash = NULL WHERE id = ?')->execute([$id]);
$message = "License #{$id} : verrouillage des paramètres avancés retiré.";
} else {
if (strlen($pwd) < 4) {
throw new Exception('Mot de passe trop court (minimum 4 caractères).');
}
// SHA-256 hex lowercase, format compatible avec SettingsLockService côté launcher.
$hash = hash('sha256', $pwd);
$db->prepare('UPDATE licenses SET settings_lock_password_hash = ? WHERE id = ?')->execute([$hash, $id]);
$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]);
$message = "Toutes les machines libérées pour la license #{$id}.";
}
elseif ($action === 'remove_machine') {
$licenseId = (int)($_POST['license_id'] ?? 0);
$machineId = trim((string)($_POST['machine_id'] ?? ''));
if ($licenseId <= 0 || $machineId === '') {
throw new Exception('license_id et machine_id requis');
}
$stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?');
$stmt->execute([$licenseId, $machineId]);
$count = $stmt->rowCount();
$shortId = substr($machineId, 0, 12);
$message = $count > 0
? "Machine {$shortId}… libérée pour la license #{$licenseId}."
: "Aucune machine correspondante trouvée pour license #{$licenseId}.";
}
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$availableChannels = listAvailableChannels();
$licenses = $db->query(
'SELECT l.*,
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
FROM licenses l
ORDER BY l.id DESC'
)->fetchAll();
// Pré-fetch toutes les machines de toutes les licenses en une seule requête,
// indexées par license_id pour l'affichage par-row sans N+1.
$allMachines = $db->query(
'SELECT license_id, machine_id, machine_label, first_seen, last_seen
FROM license_machines
ORDER BY last_seen DESC'
)->fetchAll();
$machinesByLicense = [];
foreach ($allMachines as $m) {
$machinesByLicense[(int)$m['license_id']][] = $m;
}
Layout::header('Licenses', 'licenses');
?>
<h1>Licenses</h1>
<?php Layout::flash($message, $messageType); ?>
<?php if ($newKey): ?>
<div class="card" style="border-color: var(--installed); background: var(--installed-bg);">
<h2 style="color: #6EE7B7; margin-top:0">⚡ Nouvelle clé — copie-la dès maintenant</h2>
<pre style="font-size: 18px; font-weight: bold; color: white; background: rgba(0,0,0,0.3); margin: 0;"><?= htmlspecialchars($newKey) ?></pre>
<p class="muted" style="margin-bottom: 0; margin-top: 12px;">Elle est stockée hashée — la prochaine fois que tu rechargeras cette page elle ne sera plus visible.</p>
</div>
<?php endif; ?>
<div class="card">
<h2>Émettre une license</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="create">
<div class="row">
<div class="col field">
<label>Nom du client</label>
<input type="text" name="owner" required placeholder="ACME Corp">
</div>
<div class="col field">
<label>Date d'expiration des téléchargements</label>
<input type="date" name="until" required>
</div>
<div class="col field" style="flex: 0 0 140px">
<label>Max machines</label>
<input type="number" name="max_machines" value="1" min="1" max="100">
</div>
</div>
<div class="row">
<div class="col field">
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label): ?>
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col field">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
Accès aux versions BÊTA
</label>
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
</div>
</div>
<div class="field">
<label>Notes internes (optionnel)</label>
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
</div>
<button class="btn btn-success" type="submit">Émettre la license</button>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Client</th>
<th>Émise</th>
<th>Expire</th>
<th>Machines</th>
<th>Channel / Bêta</th>
<th>Statut</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $l):
$isExpired = strtotime($l['download_entitlement_until']) < time();
$isRevoked = $l['revoked_at'] !== null;
if ($isRevoked) { $status = 'révoquée'; $cls = 'badge-danger'; }
elseif ($isExpired) { $status = 'expirée'; $cls = 'badge-warning'; }
else { $status = 'active'; $cls = 'badge-success'; }
?>
<tr>
<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; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?>
</td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
<td>
<?php if ($l['machines_count'] > 0): ?>
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
style="color: var(--text); text-decoration: none; cursor: pointer;"
title="Cliquer pour voir les machines">
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
</a>
<?php else: ?>
0 / <?= $l['max_machines'] ?>
<?php endif; ?>
</td>
<td>
<!-- 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;">
<?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'] ?>">
<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>
</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'] ?>">
<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>
</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'] ?>">
<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>
</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.
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
$machines = $machinesByLicense[(int)$l['id']] ?? [];
if (!empty($machines)):
?>
<tr>
<td colspan="8" style="padding: 0;">
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
<summary style="display: none;"></summary>
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
</div>
<table style="width: 100%; font-size: 12px; margin: 0;">
<thead>
<tr>
<th style="width: 280px;">Machine ID (SHA-256)</th>
<th>Label</th>
<th style="width: 130px;">1ère activation</th>
<th style="width: 130px;">Dernière vue</th>
<th style="width: 110px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($machines as $m):
$mid = $m['machine_id'];
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
?>
<tr>
<td>
<code title="<?= htmlspecialchars($mid) ?>"
style="font-family: 'Cascadia Code', Consolas, monospace;">
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
</code>
</td>
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
<td class="muted">
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
<?php if ($isStale): ?>
<span class="badge badge-warning" title="Pas vu depuis &gt; 30 jours, candidat à libération">stale</span>
<?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() ?>
<input type="hidden" name="action" value="remove_machine">
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
<button class="btn btn-danger" type="submit"
style="font-size: 11px; padding: 4px 10px;">
Libérer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php if (empty($licenses)): ?>
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
<?php endif; ?>
</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();