new file: CHANGELOG.md
modified: README.md modified: support-provisioning-portal/assets/portal.css modified: support-provisioning-portal/assets/portal.js modified: support-provisioning-portal/includes/class-spp-activator.php modified: support-provisioning-portal/includes/class-spp-admin-page.php modified: support-provisioning-portal/includes/class-spp-http-proxmox-client.php modified: support-provisioning-portal/includes/class-spp-mock-proxmox-client.php new file: support-provisioning-portal/includes/class-spp-permissions.php modified: support-provisioning-portal/includes/class-spp-plugin.php modified: support-provisioning-portal/includes/class-spp-repository.php modified: support-provisioning-portal/includes/class-spp-rest-controller.php modified: support-provisioning-portal/includes/class-spp-shortcode.php modified: support-provisioning-portal/includes/interface-spp-proxmox-client.php modified: support-provisioning-portal/support-provisioning-portal.php
This commit is contained in:
@@ -16,6 +16,11 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.spp-admin-stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.spp-settings,
|
||||
.spp-panel,
|
||||
.spp-card {
|
||||
@@ -28,6 +33,14 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.spp-admin-notice {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.spp-admin-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-settings label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -37,6 +50,7 @@
|
||||
|
||||
.spp-settings input,
|
||||
.spp-settings select,
|
||||
.spp-settings textarea,
|
||||
.spp-input,
|
||||
.spp-select {
|
||||
min-height: 38px;
|
||||
@@ -48,6 +62,10 @@
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.spp-settings textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.spp-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -166,6 +184,21 @@
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.spp-badge.OWNER {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.spp-badge.SHARED {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.spp-badge.ADMIN {
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.spp-badge.FAILED,
|
||||
.spp-error {
|
||||
background: #fee2e2;
|
||||
@@ -242,8 +275,51 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-share-panel {
|
||||
border-top: 1px solid var(--spp-line);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
max-width: 640px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.spp-share-panel h3,
|
||||
.spp-share-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-share-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spp-share-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.spp-share-row small {
|
||||
color: var(--spp-muted);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.spp-share-form {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.spp-form label,
|
||||
.spp-prolong-form label {
|
||||
.spp-prolong-form label,
|
||||
.spp-share-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
@@ -264,9 +340,148 @@
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.spp-user-access {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.spp-template-admin {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.spp-template-admin h3 {
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.spp-section-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.spp-section-header h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.spp-user-search {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spp-user-search input {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.spp-user-access-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.spp-user-login {
|
||||
color: var(--spp-muted);
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.spp-permission-groups {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.spp-permission-groups fieldset {
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.spp-permission-groups legend {
|
||||
font-weight: 700;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.spp-permission-groups label {
|
||||
align-items: flex-start;
|
||||
display: flex !important;
|
||||
font-weight: 400;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.spp-permission-groups input[type="checkbox"] {
|
||||
margin: 2px 0 0;
|
||||
min-height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.spp-template-list,
|
||||
.spp-pve-template-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spp-pve-template-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.spp-template-row,
|
||||
.spp-pve-template {
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.spp-template-row-head {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spp-template-row-head strong,
|
||||
.spp-template-row-head span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spp-template-row-head span {
|
||||
color: var(--spp-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.spp-template-fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.spp-template-fields label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-template-description {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.spp-template-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.spp-admin-grid,
|
||||
.spp-grid {
|
||||
.spp-grid,
|
||||
.spp-permission-groups,
|
||||
.spp-pve-template-grid,
|
||||
.spp-template-fields,
|
||||
.spp-share-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
roots.forEach((root) => {
|
||||
const api = root.dataset.restUrl;
|
||||
const nonce = root.dataset.nonce;
|
||||
const permissions = new Set(JSON.parse(root.dataset.permissions || "[]"));
|
||||
const can = (permission) => permissions.has(permission);
|
||||
let state = {
|
||||
view: "deployments",
|
||||
deployments: [],
|
||||
@@ -102,8 +104,58 @@
|
||||
render();
|
||||
};
|
||||
|
||||
const shareDeployment = async (event, id) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
|
||||
try {
|
||||
await request(`/deployments/${id}/shares`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
user: String(form.get("user"))
|
||||
})
|
||||
});
|
||||
state.selectedDeployment = await request(`/deployments/${id}`);
|
||||
state.error = null;
|
||||
} catch (error) {
|
||||
state = { ...state, error: error.message };
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const removeShare = async (id, userId) => {
|
||||
try {
|
||||
await request(`/deployments/${id}/shares/${userId}`, { method: "DELETE" });
|
||||
state.selectedDeployment = await request(`/deployments/${id}`);
|
||||
state.error = null;
|
||||
} catch (error) {
|
||||
state = { ...state, error: error.message };
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
const statusBadge = (status) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`;
|
||||
|
||||
const accessLabel = (deployment) => {
|
||||
if (deployment.accessType === "shared") {
|
||||
return '<span class="spp-badge SHARED">SHARED</span>';
|
||||
}
|
||||
|
||||
if (deployment.accessType === "admin") {
|
||||
return '<span class="spp-badge ADMIN">ADMIN</span>';
|
||||
}
|
||||
|
||||
return '<span class="spp-badge OWNER">OWNER</span>';
|
||||
};
|
||||
|
||||
const actionButton = (permission, action, label, id, className = "", disabled = false) => {
|
||||
if (!can(permission)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `<button class="spp-button ${className}" data-action="${action}" data-id="${id}" ${disabled ? "disabled" : ""} type="button">${label}</button>`;
|
||||
};
|
||||
|
||||
const ipList = (ips) => {
|
||||
if (!Array.isArray(ips) || ips.length === 0) {
|
||||
return "Pending";
|
||||
@@ -149,7 +201,7 @@
|
||||
<div class="spp-tabs">
|
||||
<button class="spp-button" data-view="deployments" type="button">Deployments</button>
|
||||
<button class="spp-button" data-view="templates" type="button">Templates</button>
|
||||
<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>
|
||||
${can("create_deployments") ? '<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -163,13 +215,14 @@
|
||||
<div class="spp-panel">
|
||||
<table class="spp-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Status</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
|
||||
<tr><th>Name</th><th>Status</th><th>Access</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${state.deployments.map((deployment) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(deployment.name)}</strong></td>
|
||||
<td>${statusBadge(deployment.status)}</td>
|
||||
<td>${accessLabel(deployment)}</td>
|
||||
<td>${escapeHtml(deployment.templateName)}</td>
|
||||
<td>${ipList(deployment.ipAddresses)}</td>
|
||||
<td>${dateTime(deployment.expiresAt)}</td>
|
||||
@@ -200,28 +253,34 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
const createView = () => `
|
||||
<div class="spp-panel" style="padding:16px;">
|
||||
<form class="spp-form" id="spp-create-form">
|
||||
<label>Template
|
||||
<select class="spp-select" name="templateId" required>
|
||||
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>Deployment name
|
||||
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
|
||||
</label>
|
||||
<label>TTL hours
|
||||
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
|
||||
</label>
|
||||
<label class="spp-check">
|
||||
<input name="neverExpire" type="checkbox" value="1">
|
||||
<span>Never expire</span>
|
||||
</label>
|
||||
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
const createView = () => {
|
||||
if (!can("create_deployments")) {
|
||||
return '<div class="spp-panel"><p style="padding:16px;margin:0;">You do not have permission to create deployments.</p></div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="spp-panel" style="padding:16px;">
|
||||
<form class="spp-form" id="spp-create-form">
|
||||
<label>Template
|
||||
<select class="spp-select" name="templateId" required>
|
||||
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
|
||||
</select>
|
||||
</label>
|
||||
<label>Deployment name
|
||||
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
|
||||
</label>
|
||||
<label>TTL hours
|
||||
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
|
||||
</label>
|
||||
<label class="spp-check">
|
||||
<input name="neverExpire" type="checkbox" value="1">
|
||||
<span>Never expire</span>
|
||||
</label>
|
||||
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const detailView = () => {
|
||||
const deployment = state.selectedDeployment;
|
||||
@@ -229,6 +288,8 @@
|
||||
return deploymentsView();
|
||||
}
|
||||
|
||||
const shares = Array.isArray(deployment.shares) ? deployment.shares : [];
|
||||
|
||||
return `
|
||||
<div class="spp-panel" style="padding:16px;">
|
||||
${deployment.status === "EXPIRED" ? `
|
||||
@@ -244,15 +305,16 @@
|
||||
${statusBadge(deployment.status)}
|
||||
</div>
|
||||
<div class="spp-actions">
|
||||
<button class="spp-button spp-button-primary" data-action="start" data-id="${deployment.id}" ${deployment.status === "RUNNING" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Start</button>
|
||||
<button class="spp-button" data-action="stop" data-id="${deployment.id}" ${deployment.status === "STOPPED" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Stop</button>
|
||||
<button class="spp-button" data-action="refresh-ips" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Refresh IPs</button>
|
||||
<button class="spp-button spp-button-danger" data-action="delete" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Delete</button>
|
||||
${actionButton("start_deployments", "start", "Start", deployment.id, "spp-button-primary", deployment.status === "RUNNING" || deployment.status === "EXPIRED")}
|
||||
${actionButton("stop_deployments", "stop", "Stop", deployment.id, "", deployment.status === "STOPPED" || deployment.status === "EXPIRED")}
|
||||
${actionButton("refresh_deployment_ips", "refresh-ips", "Refresh IPs", deployment.id, "", deployment.status === "DELETED")}
|
||||
${deployment.canDelete ? actionButton("delete_deployments", "delete", "Delete", deployment.id, "spp-button-danger", deployment.status === "DELETED") : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="spp-meta">
|
||||
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
|
||||
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
|
||||
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</strong></span>
|
||||
<span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span>
|
||||
<span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
|
||||
<span>CPU<strong>${deployment.cpuCores} cores</strong></span>
|
||||
@@ -261,17 +323,41 @@
|
||||
<span>Created<strong>${dateTime(deployment.createdAt)}</strong></span>
|
||||
<span>Expires<strong>${dateTime(deployment.expiresAt)}</strong></span>
|
||||
</div>
|
||||
<form class="spp-prolong-form" data-prolong-form="${deployment.id}">
|
||||
<h3>Prolong TTL</h3>
|
||||
<label>TTL hours
|
||||
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
|
||||
</label>
|
||||
<label class="spp-check">
|
||||
<input name="neverExpire" type="checkbox" value="1">
|
||||
<span>Never expire</span>
|
||||
</label>
|
||||
<button class="spp-button spp-button-primary" type="submit">Prolong</button>
|
||||
</form>
|
||||
${can("prolong_deployments") ? `
|
||||
<form class="spp-prolong-form" data-prolong-form="${deployment.id}">
|
||||
<h3>Prolong TTL</h3>
|
||||
<label>TTL hours
|
||||
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
|
||||
</label>
|
||||
<label class="spp-check">
|
||||
<input name="neverExpire" type="checkbox" value="1">
|
||||
<span>Never expire</span>
|
||||
</label>
|
||||
<button class="spp-button spp-button-primary" type="submit">Prolong</button>
|
||||
</form>
|
||||
` : ""}
|
||||
${deployment.canShare ? `
|
||||
<div class="spp-share-panel">
|
||||
<h3>Shared Access</h3>
|
||||
<div class="spp-share-list">
|
||||
${shares.length === 0 ? '<p>No users have shared access.</p>' : shares.map((share) => `
|
||||
<div class="spp-share-row">
|
||||
<span>
|
||||
<strong>${escapeHtml(share.displayName || share.userLogin)}</strong>
|
||||
<small>${escapeHtml(share.userLogin)}${share.userEmail ? ` - ${escapeHtml(share.userEmail)}` : ""}</small>
|
||||
</span>
|
||||
<button class="spp-button" data-remove-share="${share.id}" data-id="${deployment.id}" type="button">Remove</button>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
<form class="spp-share-form" data-share-form="${deployment.id}">
|
||||
<label>Share with user login or email
|
||||
<input class="spp-input" name="user" required>
|
||||
</label>
|
||||
<button class="spp-button spp-button-primary" type="submit">Share</button>
|
||||
</form>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -310,6 +396,10 @@
|
||||
button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action));
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-remove-share]").forEach((button) => {
|
||||
button.addEventListener("click", () => removeShare(Number(button.dataset.id), Number(button.dataset.removeShare)));
|
||||
});
|
||||
|
||||
const form = root.querySelector("#spp-create-form");
|
||||
if (form) {
|
||||
form.addEventListener("submit", createDeployment);
|
||||
@@ -338,6 +428,10 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-share-form]").forEach((form) => {
|
||||
form.addEventListener("submit", (event) => shareDeployment(event, Number(form.dataset.shareForm)));
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||
|
||||
Reference in New Issue
Block a user