diff --git a/src/license/api.mjs b/src/license/api.mjs
index 6e71b07..f271a80 100644
--- a/src/license/api.mjs
+++ b/src/license/api.mjs
@@ -61,6 +61,14 @@ export async function createProductKeys(adminToken, body) {
});
}
+/** @param {string} adminToken @param {string} key */
+export async function revokeProductKey(adminToken, key) {
+ return request(adminToken, 'v1/admin/revoke', {
+ method: 'POST',
+ body: JSON.stringify({ key }),
+ });
+}
+
/** @param {string} adminToken @param {string} key */
export async function deleteProductKey(adminToken, key) {
return request(adminToken, 'v1/admin/product-keys/delete', {
diff --git a/src/main.js b/src/main.js
index a0aab7d..32dda1b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -8,6 +8,7 @@ import {
deleteProductKey,
getDownloadStats,
listLicenses,
+ revokeProductKey,
LicenseApiError,
} from './license/api.mjs';
import { loadLicenseSettings, saveLicenseSettings } from './license/settings.mjs';
@@ -109,6 +110,16 @@ app.whenReady().then(() => {
}
});
+ ipcMain.handle('license.revokeProductKey', async (_e, key) => {
+ try {
+ const { adminToken } = loadLicenseSettings(app.getPath('userData'));
+ await revokeProductKey(adminToken, String(key ?? ''));
+ return { ok: true };
+ } catch (err) {
+ return licenseError(err);
+ }
+ });
+
ipcMain.handle('license.deleteProductKey', async (_e, key) => {
try {
const { adminToken } = loadLicenseSettings(app.getPath('userData'));
diff --git a/src/preload.cjs b/src/preload.cjs
index d5ffb7c..87db3d6 100644
--- a/src/preload.cjs
+++ b/src/preload.cjs
@@ -27,6 +27,9 @@ contextBridge.exposeInMainWorld('license', {
const res = unwrapLicense(await ipcRenderer.invoke('license.createProductKeys', body));
return res.result;
},
+ revokeProductKey: async (key) => {
+ unwrapLicense(await ipcRenderer.invoke('license.revokeProductKey', key));
+ },
deleteProductKey: async (key) => {
unwrapLicense(await ipcRenderer.invoke('license.deleteProductKey', key));
},
diff --git a/src/renderer/pages/licenseList.mjs b/src/renderer/pages/licenseList.mjs
index a992a03..56317b0 100644
--- a/src/renderer/pages/licenseList.mjs
+++ b/src/renderer/pages/licenseList.mjs
@@ -19,6 +19,8 @@ export function renderLicenseListPage(root) {
/** @type {import('../../license/models.mjs').LicenseEntry | null} */
let confirmDelete = null;
+ /** @type {import('../../license/models.mjs').LicenseEntry | null} */
+ let confirmRevoke = null;
function showError(msg) {
if (!msg) {
@@ -30,9 +32,53 @@ export function renderLicenseListPage(root) {
$listError.textContent = msg;
}
- function renderModal() {
- const existing = document.getElementById('delete-modal');
- if (existing) existing.remove();
+ function closeModal(id) {
+ document.getElementById(id)?.remove();
+ }
+
+ function renderRevokeModal() {
+ closeModal('revoke-modal');
+ if (!confirmRevoke) return;
+
+ const backdrop = document.createElement('div');
+ backdrop.id = 'revoke-modal';
+ backdrop.className = 'modal-backdrop';
+ backdrop.innerHTML = `
+
+
Отозвать лицензию?
+
Ключ ${confirmRevoke.key} перестанет работать у всех пользователей. Запись останется в списке.
+
+
+
+
+
+ `;
+ document.body.appendChild(backdrop);
+
+ backdrop.querySelector('#cancelRevoke')?.addEventListener('click', () => {
+ confirmRevoke = null;
+ backdrop.remove();
+ });
+
+ backdrop.querySelector('#confirmRevoke')?.addEventListener('click', async () => {
+ const key = confirmRevoke?.key;
+ if (!key || !window.license) return;
+ backdrop.remove();
+ confirmRevoke = null;
+ $refreshBtn.disabled = true;
+ try {
+ await window.license.revokeProductKey(key);
+ await loadLicenses();
+ } catch (err) {
+ showError(err instanceof Error ? err.message : String(err));
+ } finally {
+ $refreshBtn.disabled = false;
+ }
+ });
+ }
+
+ function renderDeleteModal() {
+ closeModal('delete-modal');
if (!confirmDelete) return;
const backdrop = document.createElement('div');
@@ -84,9 +130,10 @@ export function renderLicenseListPage(root) {
const devices = lic.activatedDevices?.length
? `deviceId: ${lic.activatedDevices.join(', ')}
`
: '';
- const deleteBtn = !lic.revoked
- ? ``
+ const revokeBtn = !lic.revoked
+ ? ``
: '';
+ const deleteBtn = ``;
return `
${lic.key}
@@ -96,7 +143,7 @@ export function renderLicenseListPage(root) {
Срок: ${formatLicenseExpiry(lic)}
Статус: ${lic.revoked ? 'Отозвана' : 'Активна'}
${devices}
- ${deleteBtn}
+
${revokeBtn}${deleteBtn}
`;
})
@@ -104,13 +151,24 @@ export function renderLicenseListPage(root) {
$listContent.innerHTML = `${html}
`;
+ $listContent.querySelectorAll('[data-revoke]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const key = btn.getAttribute('data-revoke');
+ const lic = licenses.find((l) => l.key === key);
+ if (lic) {
+ confirmRevoke = lic;
+ renderRevokeModal();
+ }
+ });
+ });
+
$listContent.querySelectorAll('[data-delete]').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.getAttribute('data-delete');
const lic = licenses.find((l) => l.key === key);
if (lic) {
confirmDelete = lic;
- renderModal();
+ renderDeleteModal();
}
});
});
@@ -139,6 +197,7 @@ export function renderLicenseListPage(root) {
void loadLicenses();
return () => {
- document.getElementById('delete-modal')?.remove();
+ closeModal('delete-modal');
+ closeModal('revoke-modal');
};
}
diff --git a/src/styles/app.css b/src/styles/app.css
index 5685967..b747edb 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -17,22 +17,30 @@
box-sizing: border-box;
}
+html,
+body {
+ height: 100%;
+ overflow: hidden;
+}
+
body {
margin: 0;
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
- min-height: 100vh;
}
.app-shell {
display: flex;
- min-height: 100vh;
+ height: 100vh;
+ overflow: hidden;
}
.sidebar {
width: 200px;
flex-shrink: 0;
+ height: 100vh;
+ overflow: hidden;
border-right: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
padding: 20px 0;
@@ -90,7 +98,9 @@ body {
.main {
flex: 1;
- overflow: auto;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
padding: 24px 28px 32px;
max-width: 760px;
}
@@ -408,6 +418,7 @@ button.danger:hover:not(:disabled) {
margin-top: 12px;
display: flex;
justify-content: flex-end;
+ gap: 8px;
}
.empty,