798 lines
34 KiB
PHP
798 lines
34 KiB
PHP
<?php
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
|
|
function clm_local_datetime_value($value): string
|
|
{
|
|
if (empty($value)) {
|
|
return '';
|
|
}
|
|
|
|
$time = strtotime((string)$value);
|
|
return $time ? date('Y-m-d\TH:i', $time) : '';
|
|
}
|
|
|
|
$schemaError = '';
|
|
try {
|
|
clm_ensure_schema();
|
|
} catch (Throwable $e) {
|
|
$schemaError = $e->getMessage();
|
|
}
|
|
|
|
if (isset($_GET['logout'])) {
|
|
unset($_SESSION['clm_admin_auth']);
|
|
clm_redirect('manage.php');
|
|
}
|
|
|
|
$error = $schemaError;
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') {
|
|
if ($schemaError !== '') {
|
|
$error = $schemaError;
|
|
} elseif (hash_equals((string)clm_cfg('admin_password'), (string)($_POST['password'] ?? ''))) {
|
|
$_SESSION['clm_admin_auth'] = true;
|
|
clm_redirect('manage.php');
|
|
} else {
|
|
$error = 'Wrong admin password.';
|
|
}
|
|
}
|
|
|
|
$isAuthenticated = !empty($_SESSION['clm_admin_auth']);
|
|
|
|
if ($isAuthenticated && $schemaError === '' && $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') !== 'login') {
|
|
try {
|
|
$action = (string)($_POST['action'] ?? '');
|
|
$tables = clm_tables();
|
|
$activationsTable = $tables['activations'];
|
|
|
|
switch ($action) {
|
|
case 'create_app':
|
|
$name = trim((string)($_POST['name'] ?? ''));
|
|
$slug = trim((string)($_POST['slug'] ?? ''));
|
|
if ($name === '' && $slug === '') {
|
|
throw new RuntimeException('App name is required.');
|
|
}
|
|
$app = clm_resolve_app($slug, $name, true);
|
|
if (!$app) {
|
|
throw new RuntimeException('App could not be created.');
|
|
}
|
|
clm_set_flash('success', 'App saved: ' . $app['name']);
|
|
break;
|
|
|
|
case 'save_app':
|
|
$app = clm_app_by_id((int)($_POST['id'] ?? 0));
|
|
if (!$app) {
|
|
throw new RuntimeException('App not found.');
|
|
}
|
|
$app = clm_update_app_record($app, [
|
|
'name' => $_POST['name'] ?? '',
|
|
'slug' => $_POST['slug'] ?? '',
|
|
'status' => $_POST['status'] ?? 'active',
|
|
]);
|
|
clm_set_flash('success', 'App updated: ' . $app['name']);
|
|
break;
|
|
|
|
case 'issue_license':
|
|
$license = clm_issue_license_record([
|
|
'app_slug' => $_POST['app_slug'] ?? '',
|
|
'app_name' => $_POST['app_name'] ?? '',
|
|
'prefix' => $_POST['prefix'] ?? 'FLAT',
|
|
'max_activations' => $_POST['max_activations'] ?? 1,
|
|
'max_counters' => $_POST['max_counters'] ?? 1,
|
|
'owner' => $_POST['owner'] ?? '',
|
|
'customer_name' => $_POST['customer_name'] ?? '',
|
|
'customer_email' => $_POST['customer_email'] ?? '',
|
|
'address' => $_POST['address'] ?? '',
|
|
'expires_at' => $_POST['expires_at'] ?? '',
|
|
'notes' => $_POST['notes'] ?? '',
|
|
]);
|
|
clm_set_flash('success', 'License issued: ' . $license['license_key']);
|
|
break;
|
|
|
|
case 'save_license':
|
|
$license = clm_license_with_app_by_id((int)($_POST['id'] ?? 0));
|
|
if (!$license) {
|
|
throw new RuntimeException('License not found.');
|
|
}
|
|
clm_update_license_record($license, [
|
|
'owner' => $_POST['owner'] ?? '',
|
|
'customer_name' => $_POST['customer_name'] ?? '',
|
|
'customer_email' => $_POST['customer_email'] ?? '',
|
|
'app_slug' => $_POST['app_slug'] ?? '',
|
|
'max_activations' => $_POST['max_activations'] ?? 1,
|
|
'max_counters' => $_POST['max_counters'] ?? 1,
|
|
'expires_at' => $_POST['expires_at'] ?? '',
|
|
'status' => $_POST['status'] ?? 'active',
|
|
'notes' => $_POST['notes'] ?? '',
|
|
]);
|
|
clm_set_flash('success', 'License updated.');
|
|
break;
|
|
|
|
case 'toggle_license':
|
|
$license = clm_license_with_app_by_id((int)($_POST['id'] ?? 0));
|
|
if (!$license) {
|
|
throw new RuntimeException('License not found.');
|
|
}
|
|
$nextStatus = ($license['status'] ?? 'active') === 'active' ? 'suspended' : 'active';
|
|
clm_update_license_record($license, ['status' => $nextStatus]);
|
|
clm_set_flash('success', 'License status changed to ' . $nextStatus . '.');
|
|
break;
|
|
|
|
case 'deactivate_activation':
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
throw new RuntimeException('Activation not found.');
|
|
}
|
|
$stmt = clm_db()->prepare("UPDATE `{$activationsTable}` SET status = 'deactivated', deactivated_at = NOW(), last_seen_at = NOW() WHERE id = ?");
|
|
$stmt->execute([$id]);
|
|
clm_set_flash('success', 'Activation deactivated.');
|
|
break;
|
|
}
|
|
} catch (Throwable $e) {
|
|
clm_set_flash('error', $e->getMessage());
|
|
}
|
|
|
|
clm_redirect('manage.php');
|
|
}
|
|
|
|
$flash = clm_get_flash();
|
|
|
|
if (!$isAuthenticated):
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Central License Manager Login</title>
|
|
<meta name="description" content="Admin login for the Central License Manager.">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<style>
|
|
:root {
|
|
--bg: #f6f8fb;
|
|
--card: rgba(255,255,255,0.94);
|
|
--text: #0f172a;
|
|
--muted: #64748b;
|
|
--line: #d9e2ec;
|
|
--accent: #0ea5e9;
|
|
--danger: #b91c1c;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 20px;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background: radial-gradient(circle at top left, rgba(14,165,233,0.14), transparent 24%), var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.card {
|
|
width: min(100%, 460px);
|
|
background: var(--card);
|
|
border: 1px solid var(--line);
|
|
border-radius: 28px;
|
|
padding: 28px;
|
|
box-shadow: 0 24px 54px rgba(15, 23, 42, 0.09);
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
padding: 8px 12px;
|
|
border-radius: 999px;
|
|
background: rgba(14,165,233,0.09);
|
|
color: #0284c7;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
h1 { margin: 14px 0 10px; font-size: 2rem; }
|
|
p { color: var(--muted); line-height: 1.65; }
|
|
label { display: block; font-size: 0.9rem; font-weight: 700; margin: 14px 0 8px; }
|
|
input {
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
padding: 13px 14px;
|
|
font: inherit;
|
|
}
|
|
button, a.button {
|
|
width: 100%;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-top: 16px;
|
|
padding: 13px 16px;
|
|
border-radius: 16px;
|
|
border: none;
|
|
background: linear-gradient(135deg, #0ea5e9, #0284c7);
|
|
color: white;
|
|
font: inherit;
|
|
font-weight: 800;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.alt {
|
|
background: white;
|
|
color: var(--text);
|
|
border: 1px solid var(--line);
|
|
}
|
|
.error {
|
|
margin-top: 14px;
|
|
padding: 12px 14px;
|
|
border-radius: 16px;
|
|
background: rgba(185, 28, 28, 0.08);
|
|
color: var(--danger);
|
|
border: 1px solid rgba(185, 28, 28, 0.15);
|
|
}
|
|
code {
|
|
display: block;
|
|
margin-top: 12px;
|
|
padding: 12px 14px;
|
|
border-radius: 16px;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 13px;
|
|
overflow-x: auto;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<span class="badge">Admin access</span>
|
|
<h1>Central License Manager</h1>
|
|
<p>Use the configured admin password to manage apps, licenses, and activations from one place.</p>
|
|
<code>Mode: <?= clm_html(clm_manager_mode_label()) ?></code>
|
|
<?php if ($error !== ''): ?>
|
|
<div class="error"><?= clm_html($error) ?></div>
|
|
<?php endif; ?>
|
|
<form method="post">
|
|
<input type="hidden" name="action" value="login">
|
|
<label for="password">Admin password</label>
|
|
<input id="password" type="password" name="password" required autofocus>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
<a class="button alt" href="index.php">Back to module home</a>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
<?php
|
|
exit;
|
|
endif;
|
|
|
|
$tables = clm_tables();
|
|
$appsTable = $tables['apps'];
|
|
$licensesTable = $tables['licenses'];
|
|
$activationsTable = $tables['activations'];
|
|
|
|
$stats = [
|
|
'apps' => 0,
|
|
'licenses' => 0,
|
|
'activations' => 0,
|
|
];
|
|
$apps = [];
|
|
$licenses = [];
|
|
$activations = [];
|
|
|
|
try {
|
|
$pdo = clm_db();
|
|
$stats['apps'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$appsTable}`")->fetchColumn();
|
|
$stats['licenses'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$licensesTable}`")->fetchColumn();
|
|
$stats['activations'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$activationsTable}` WHERE status = 'active'")->fetchColumn();
|
|
|
|
$apps = $pdo->query("SELECT a.*,
|
|
(SELECT COUNT(*) FROM `{$licensesTable}` l WHERE l.app_id = a.id) AS licenses_count,
|
|
(SELECT COUNT(*)
|
|
FROM `{$activationsTable}` act
|
|
INNER JOIN `{$licensesTable}` l2 ON l2.id = act.license_id
|
|
WHERE l2.app_id = a.id AND act.status = 'active') AS active_activations
|
|
FROM `{$appsTable}` a
|
|
ORDER BY a.name ASC")->fetchAll();
|
|
|
|
$licenses = $pdo->query("SELECT l.*, a.slug AS app_slug, a.name AS app_name,
|
|
(SELECT COUNT(*) FROM `{$activationsTable}` act WHERE act.license_id = l.id AND act.status = 'active') AS active_activations
|
|
FROM `{$licensesTable}` l
|
|
INNER JOIN `{$appsTable}` a ON a.id = l.app_id
|
|
ORDER BY l.created_at DESC
|
|
LIMIT 200")->fetchAll();
|
|
|
|
$activations = $pdo->query("SELECT act.*, l.license_key, a.slug AS app_slug, a.name AS app_name
|
|
FROM `{$activationsTable}` act
|
|
INNER JOIN `{$licensesTable}` l ON l.id = act.license_id
|
|
INNER JOIN `{$appsTable}` a ON a.id = l.app_id
|
|
ORDER BY COALESCE(act.last_seen_at, act.activated_at) DESC
|
|
LIMIT 200")->fetchAll();
|
|
} catch (Throwable $e) {
|
|
$flash = ['type' => 'error', 'message' => $e->getMessage()];
|
|
}
|
|
|
|
$baseUrl = clm_base_url();
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Central License Manager Dashboard</title>
|
|
<meta name="description" content="Manage apps, licenses, and activations from a single central dashboard.">
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<style>
|
|
:root {
|
|
--bg: #f6f8fb;
|
|
--card: rgba(255,255,255,0.93);
|
|
--text: #0f172a;
|
|
--muted: #64748b;
|
|
--line: #d9e2ec;
|
|
--accent: #0ea5e9;
|
|
--accent-strong: #0284c7;
|
|
--success: #15803d;
|
|
--danger: #b91c1c;
|
|
--warning: #b45309;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(14,165,233,0.14), transparent 24%),
|
|
radial-gradient(circle at top right, rgba(34,197,94,0.08), transparent 20%),
|
|
var(--bg);
|
|
color: var(--text);
|
|
}
|
|
.shell { max-width: 1320px; margin: 0 auto; padding: 28px 20px 48px; }
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid rgba(217,226,236,0.95);
|
|
border-radius: 24px;
|
|
padding: 22px;
|
|
box-shadow: 0 22px 50px rgba(15, 23, 42, 0.07);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.hero {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.8fr);
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
padding: 8px 12px;
|
|
border-radius: 999px;
|
|
background: rgba(14,165,233,0.09);
|
|
color: var(--accent-strong);
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
h1, h2, h3 { margin: 0 0 12px; }
|
|
h1 { font-size: clamp(2rem, 3.4vw, 3.1rem); line-height: 1.02; }
|
|
p { margin: 0 0 12px; color: var(--muted); line-height: 1.65; }
|
|
.top-actions, .row-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.button, button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
border-radius: 16px;
|
|
padding: 12px 16px;
|
|
font: inherit;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
background: linear-gradient(135deg, #0ea5e9, #0284c7);
|
|
color: white;
|
|
}
|
|
.button.alt, button.alt {
|
|
background: white;
|
|
color: var(--text);
|
|
border: 1px solid var(--line);
|
|
}
|
|
.stats, .forms {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.forms { grid-template-columns: 1fr 1.2fr 1fr; }
|
|
.stat strong { display: block; font-size: 1.8rem; margin-bottom: 6px; }
|
|
.code {
|
|
display: block;
|
|
margin-top: 12px;
|
|
padding: 14px 16px;
|
|
border-radius: 18px;
|
|
background: #0f172a;
|
|
color: #e2e8f0;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
font-size: 13px;
|
|
overflow-x: auto;
|
|
}
|
|
.notice {
|
|
margin-bottom: 18px;
|
|
padding: 14px 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid transparent;
|
|
}
|
|
.notice.success { background: rgba(21,128,61,0.09); color: var(--success); border-color: rgba(21,128,61,0.16); }
|
|
.notice.error { background: rgba(185,28,28,0.08); color: var(--danger); border-color: rgba(185,28,28,0.16); }
|
|
.field-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
input, select, textarea {
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 12px 13px;
|
|
font: inherit;
|
|
background: white;
|
|
color: var(--text);
|
|
}
|
|
textarea { min-height: 108px; resize: vertical; }
|
|
.section { margin-top: 20px; }
|
|
.table-wrap { overflow-x: auto; }
|
|
table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
|
.table-input, .table-select {
|
|
min-width: 180px;
|
|
padding: 10px 12px;
|
|
}
|
|
.table-actions { white-space: nowrap; }
|
|
th, td {
|
|
padding: 13px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
text-align: left;
|
|
vertical-align: top;
|
|
font-size: 0.95rem;
|
|
}
|
|
th { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.licenses {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 16px;
|
|
}
|
|
.license-card {
|
|
background: rgba(255,255,255,0.98);
|
|
border: 1px solid var(--line);
|
|
border-radius: 22px;
|
|
padding: 18px;
|
|
}
|
|
.license-head {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
}
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 7px 11px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
.pill.active { background: rgba(21,128,61,0.1); color: var(--success); }
|
|
.pill.suspended { background: rgba(180,83,9,0.1); color: var(--warning); }
|
|
.pill.expired, .pill.deactivated { background: rgba(185,28,28,0.1); color: var(--danger); }
|
|
.meta { color: var(--muted); font-size: 0.9rem; }
|
|
.split { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
|
|
.split > * { flex: 1 1 180px; }
|
|
.small-form { display: inline; }
|
|
.small-btn {
|
|
border: 1px solid var(--line);
|
|
background: white;
|
|
color: var(--text);
|
|
padding: 8px 12px;
|
|
border-radius: 12px;
|
|
font-weight: 700;
|
|
}
|
|
@media (max-width: 1120px) {
|
|
.hero, .forms, .stats, .licenses { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 720px) {
|
|
.field-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<section class="hero">
|
|
<article class="card">
|
|
<span class="badge">Multi-app dashboard</span>
|
|
<h1><?= clm_html((string)clm_cfg('manager_name')) ?></h1>
|
|
<p>Manage licenses and installations for multiple products from one isolated module. This dashboard is intentionally separate from the old in-app license manager so you can move it later.</p>
|
|
<div class="top-actions">
|
|
<a class="button" href="index.php">Module home</a>
|
|
<a class="button alt" href="install.php">Installer</a>
|
|
<a class="button alt" href="manage.php?logout=1">Logout</a>
|
|
</div>
|
|
</article>
|
|
<aside class="card">
|
|
<h2>Integration</h2>
|
|
<p><?= clm_html(clm_manager_mode_label()) ?></p>
|
|
<span class="code">LICENSE_API_URL=<?= clm_html($baseUrl) ?></span>
|
|
<p style="margin-top: 12px;">Change <code>CLM_API_SECRET</code> and <code>CLM_ADMIN_PASSWORD</code> before exposing this module publicly.</p>
|
|
</aside>
|
|
</section>
|
|
|
|
<?php if ($flash): ?>
|
|
<div class="notice <?= $flash['type'] === 'success' ? 'success' : 'error' ?>">
|
|
<?= clm_html($flash['message']) ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<section class="stats">
|
|
<article class="card stat">
|
|
<strong><?= (int)$stats['apps'] ?></strong>
|
|
<span>Apps</span>
|
|
</article>
|
|
<article class="card stat">
|
|
<strong><?= (int)$stats['licenses'] ?></strong>
|
|
<span>Licenses</span>
|
|
</article>
|
|
<article class="card stat">
|
|
<strong><?= (int)$stats['activations'] ?></strong>
|
|
<span>Active installs</span>
|
|
</article>
|
|
<article class="card stat">
|
|
<strong><?= clm_html(date('Y-m-d')) ?></strong>
|
|
<span>Manager date</span>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="forms">
|
|
<article class="card">
|
|
<h2>Create app</h2>
|
|
<form method="post">
|
|
<input type="hidden" name="action" value="create_app">
|
|
<label>App name
|
|
<input type="text" name="name" placeholder="Bilingual Accounting">
|
|
</label>
|
|
<label>Slug
|
|
<input type="text" name="slug" placeholder="bilingual-accounting">
|
|
</label>
|
|
<p class="meta">You can rename the app or adjust its slug later in the Apps table.</p>
|
|
<button type="submit">Save app</button>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<h2>Issue license</h2>
|
|
<form method="post">
|
|
<input type="hidden" name="action" value="issue_license">
|
|
<div class="field-grid">
|
|
<label>App
|
|
<select name="app_slug">
|
|
<?php foreach ($apps as $app): ?>
|
|
<option value="<?= clm_html($app['slug']) ?>"><?= clm_html($app['name']) ?> (<?= clm_html($app['slug']) ?>)</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<label>Key prefix
|
|
<input type="text" name="prefix" value="FLAT">
|
|
</label>
|
|
<label>Owner
|
|
<input type="text" name="owner" placeholder="Company owner">
|
|
</label>
|
|
<label>Customer name
|
|
<input type="text" name="customer_name" placeholder="Client name">
|
|
</label>
|
|
<label>Customer email
|
|
<input type="email" name="customer_email" placeholder="client@example.com">
|
|
</label>
|
|
<label>Max activations
|
|
<input type="number" name="max_activations" value="1" min="1">
|
|
</label>
|
|
<label>Max counters
|
|
<input type="number" name="max_counters" value="1" min="1">
|
|
</label>
|
|
<label>Expires at
|
|
<input type="datetime-local" name="expires_at">
|
|
</label>
|
|
</div>
|
|
<label>Notes
|
|
<textarea name="notes" placeholder="Optional internal note"></textarea>
|
|
</label>
|
|
<button type="submit">Issue license</button>
|
|
</form>
|
|
</article>
|
|
|
|
<article class="card">
|
|
<h2>What to point clients to</h2>
|
|
<p>Each app should call this module, not keep its own separate activation database.</p>
|
|
<span class="code"><?= clm_html($baseUrl) ?></span>
|
|
<p style="margin-top: 14px;">Current API stays backward compatible with older clients by returning both <code>max_activations</code> and <code>allowed_activations</code>.</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="section card">
|
|
<h2>Apps</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Slug</th>
|
|
<th>Status</th>
|
|
<th>Licenses</th>
|
|
<th>Active installs</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php if (!$apps): ?>
|
|
<tr><td colspan="6">No apps yet.</td></tr>
|
|
<?php else: ?>
|
|
<?php foreach ($apps as $app): ?>
|
|
<?php $appFormId = 'app-form-' . (int)$app['id']; ?>
|
|
<tr>
|
|
<td>
|
|
<form id="<?= clm_html($appFormId) ?>" method="post">
|
|
<input type="hidden" name="action" value="save_app">
|
|
<input type="hidden" name="id" value="<?= (int)$app['id'] ?>">
|
|
</form>
|
|
<input form="<?= clm_html($appFormId) ?>" class="table-input" type="text" name="name" value="<?= clm_html($app['name']) ?>">
|
|
</td>
|
|
<td>
|
|
<input form="<?= clm_html($appFormId) ?>" class="table-input" type="text" name="slug" value="<?= clm_html($app['slug']) ?>">
|
|
<div class="meta">Update client settings too if you rename this slug.</div>
|
|
</td>
|
|
<td>
|
|
<select form="<?= clm_html($appFormId) ?>" class="table-select" name="status">
|
|
<?php foreach (['active', 'inactive'] as $status): ?>
|
|
<option value="<?= $status ?>" <?= $app['status'] === $status ? 'selected' : '' ?>><?= ucfirst($status) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</td>
|
|
<td><?= (int)$app['licenses_count'] ?></td>
|
|
<td><?= (int)$app['active_activations'] ?></td>
|
|
<td class="table-actions">
|
|
<button form="<?= clm_html($appFormId) ?>" class="small-btn" type="submit">Save</button>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<h2>Licenses</h2>
|
|
<div class="licenses">
|
|
<?php if (!$licenses): ?>
|
|
<article class="card"><p>No licenses yet.</p></article>
|
|
<?php else: ?>
|
|
<?php foreach ($licenses as $license): ?>
|
|
<article class="license-card">
|
|
<div class="license-head">
|
|
<div>
|
|
<strong><code><?= clm_html($license['license_key']) ?></code></strong>
|
|
<div class="meta"><?= clm_html($license['app_name']) ?> · <?= clm_html($license['app_slug']) ?></div>
|
|
</div>
|
|
<span class="pill <?= clm_html($license['status']) ?>"><?= clm_html($license['status']) ?></span>
|
|
</div>
|
|
|
|
<form method="post">
|
|
<input type="hidden" name="action" value="save_license">
|
|
<input type="hidden" name="id" value="<?= (int)$license['id'] ?>">
|
|
<div class="field-grid">
|
|
<label>Owner
|
|
<input type="text" name="owner" value="<?= clm_html($license['owner']) ?>">
|
|
</label>
|
|
<label>Customer name
|
|
<input type="text" name="customer_name" value="<?= clm_html($license['customer_name']) ?>">
|
|
</label>
|
|
<label>Customer email
|
|
<input type="email" name="customer_email" value="<?= clm_html($license['customer_email']) ?>">
|
|
</label>
|
|
<label>App
|
|
<select name="app_slug">
|
|
<?php foreach ($apps as $appOption): ?>
|
|
<option value="<?= clm_html($appOption['slug']) ?>" <?= $license['app_slug'] === $appOption['slug'] ? 'selected' : '' ?>><?= clm_html($appOption['name']) ?> (<?= clm_html($appOption['slug']) ?>)</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<label>Max activations
|
|
<input type="number" name="max_activations" min="1" value="<?= (int)$license['max_activations'] ?>">
|
|
</label>
|
|
<label>Max counters
|
|
<input type="number" name="max_counters" min="1" value="<?= (int)$license['max_counters'] ?>">
|
|
</label>
|
|
<label>Expires at
|
|
<input type="datetime-local" name="expires_at" value="<?= clm_html(clm_local_datetime_value($license['expires_at'])) ?>">
|
|
</label>
|
|
<label>Status
|
|
<select name="status">
|
|
<?php foreach (['active', 'suspended', 'expired'] as $status): ?>
|
|
<option value="<?= $status ?>" <?= $license['status'] === $status ? 'selected' : '' ?>><?= ucfirst($status) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<label>Notes
|
|
<textarea name="notes"><?= clm_html($license['notes']) ?></textarea>
|
|
</label>
|
|
</div>
|
|
<div class="split">
|
|
<div class="meta">Created: <?= clm_html((string)$license['created_at']) ?></div>
|
|
<div class="meta">Active installs: <?= (int)$license['active_activations'] ?></div>
|
|
</div>
|
|
<div class="row-actions" style="margin-top: 14px;">
|
|
<button type="submit">Save license</button>
|
|
</div>
|
|
</form>
|
|
|
|
<form method="post" class="row-actions" style="margin-top: 10px;">
|
|
<input type="hidden" name="action" value="toggle_license">
|
|
<input type="hidden" name="id" value="<?= (int)$license['id'] ?>">
|
|
<button class="alt" type="submit"><?= $license['status'] === 'active' ? 'Suspend' : 'Activate' ?></button>
|
|
</form>
|
|
</article>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section card">
|
|
<h2>Recent activations</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>License</th>
|
|
<th>App</th>
|
|
<th>Domain</th>
|
|
<th>Fingerprint</th>
|
|
<th>Product</th>
|
|
<th>Status</th>
|
|
<th>Activated</th>
|
|
<th>Last seen</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php if (!$activations): ?>
|
|
<tr><td colspan="9">No activations yet.</td></tr>
|
|
<?php else: ?>
|
|
<?php foreach ($activations as $activation): ?>
|
|
<tr>
|
|
<td><code><?= clm_html($activation['license_key']) ?></code></td>
|
|
<td><?= clm_html($activation['app_name']) ?></td>
|
|
<td><?= clm_html($activation['domain']) ?></td>
|
|
<td><code><?= clm_html(substr((string)$activation['fingerprint'], 0, 16)) ?>…</code></td>
|
|
<td><?= clm_html($activation['product']) ?></td>
|
|
<td><span class="pill <?= clm_html($activation['status']) ?>"><?= clm_html($activation['status']) ?></span></td>
|
|
<td><?= clm_html((string)$activation['activated_at']) ?></td>
|
|
<td><?= clm_html((string)($activation['last_seen_at'] ?: '—')) ?></td>
|
|
<td>
|
|
<?php if (($activation['status'] ?? 'active') === 'active'): ?>
|
|
<form method="post" class="small-form">
|
|
<input type="hidden" name="action" value="deactivate_activation">
|
|
<input type="hidden" name="id" value="<?= (int)$activation['id'] ?>">
|
|
<button class="small-btn" type="submit">Deactivate</button>
|
|
</form>
|
|
<?php else: ?>
|
|
<span class="meta">Inactive</span>
|
|
<?php endif; ?>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|