RBAC 2.0
This commit is contained in:
@@ -141,7 +141,7 @@ async function init() {
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role === "admin" || data.role === "operator") {
|
||||
if (data.role === "admin" || data.role === "operator" || data.canBuild) {
|
||||
buildLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -149,10 +149,10 @@ async function init() {
|
||||
scriptsLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role !== "admin" && data.role !== "operator") {
|
||||
if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) {
|
||||
buildBtn.disabled = true;
|
||||
buildBtn.innerHTML =
|
||||
'<i class="fa-solid fa-lock"></i> <span>Build requires admin/operator role</span>';
|
||||
'<i class="fa-solid fa-lock"></i> <span>Build requires permission</span>';
|
||||
}
|
||||
|
||||
await loadServerVersion();
|
||||
@@ -539,13 +539,14 @@ async function deleteBuild(buildId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/build/${buildId}/delete`, {
|
||||
const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to delete build");
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to delete build");
|
||||
}
|
||||
|
||||
const buildElement = document.getElementById(`build-${buildId}`);
|
||||
|
||||
@@ -186,7 +186,7 @@ async function loadCurrentUser() {
|
||||
usersLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.role === "admin" || currentUser.role === "operator") {
|
||||
if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) {
|
||||
buildLink?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +550,7 @@ async function checkAuth() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
document.getElementById("plugins-link")?.classList.remove("hidden");
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
} else if (data.role === "operator") {
|
||||
} else if (data.role === "operator" || data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
pluginsLink,
|
||||
scriptsLink,
|
||||
logsLink,
|
||||
notificationsLink,
|
||||
} = refs;
|
||||
|
||||
if (!user || !usernameDisplay || !roleBadge) return;
|
||||
@@ -68,6 +69,7 @@ export function applyUserRoleUI(user, refs) {
|
||||
}
|
||||
if (user.role === "admin" || user.role === "operator") {
|
||||
buildLink?.classList.remove("hidden");
|
||||
notificationsLink?.classList.remove("hidden");
|
||||
}
|
||||
if (user.role !== "viewer") {
|
||||
scriptsLink?.classList.remove("hidden");
|
||||
|
||||
@@ -62,7 +62,7 @@ export function mountNav(host) {
|
||||
<a
|
||||
href="/notifications"
|
||||
id="notifications-link"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
class="hidden inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900/70 border border-slate-800 hover:bg-slate-800 text-slate-300 transition-colors"
|
||||
><i class="fa-solid fa-bell text-yellow-400"></i> Notifications</a
|
||||
>
|
||||
<a
|
||||
@@ -128,5 +128,6 @@ export function mountNav(host) {
|
||||
pluginsLink: document.getElementById("plugins-link"),
|
||||
scriptsLink: document.getElementById("scripts-link"),
|
||||
logsLink: document.getElementById("logs-link"),
|
||||
notificationsLink: document.getElementById("notifications-link"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ async function checkAuth() {
|
||||
document.getElementById("users-link")?.classList.remove("hidden");
|
||||
document.getElementById("plugins-link")?.classList.remove("hidden");
|
||||
document.getElementById("deploy-link")?.classList.remove("hidden");
|
||||
} else if (data.role === "operator") {
|
||||
} else if (data.role === "operator" || data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ async function checkAuth() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (data.role === "viewer") {
|
||||
alert("Access denied. Operator or Admin role required.");
|
||||
window.location.href = "/";
|
||||
|
||||
@@ -19,6 +19,9 @@ const prefsForm = document.getElementById("prefs-form");
|
||||
const prefNotificationsInput = document.getElementById("pref-notifications");
|
||||
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
|
||||
|
||||
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
|
||||
const saveMyTelegramBtn = document.getElementById("save-my-telegram");
|
||||
|
||||
const bansTableBody = document.getElementById("bans-table-body");
|
||||
const bansPermissionNote = document.getElementById("bans-permission-note");
|
||||
const refreshBansBtn = document.getElementById("refresh-bans-btn");
|
||||
@@ -447,6 +450,46 @@ async function runCertbotAutoSetup() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyTelegram() {
|
||||
if (!myTelegramChatIdInput) return;
|
||||
if (currentUser?.telegramChatId) {
|
||||
myTelegramChatIdInput.value = currentUser.telegramChatId;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch("/api/settings/telegram", { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
myTelegramChatIdInput.value = data.telegramChatId || "";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMyTelegram() {
|
||||
if (!myTelegramChatIdInput) return;
|
||||
const chatId = myTelegramChatIdInput.value.trim();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/telegram", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ telegramChatId: chatId }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showMessage(data.error || "Failed to save Telegram settings.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
myTelegramChatIdInput.value = data.telegramChatId || "";
|
||||
showMessage(chatId ? "Telegram chat ID saved." : "Telegram notifications disabled.");
|
||||
} catch {
|
||||
showMessage("Failed to save Telegram settings.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannedIps() {
|
||||
if (!currentUser) return;
|
||||
|
||||
@@ -537,12 +580,14 @@ async function init() {
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
loadPrefs();
|
||||
await loadMyTelegram();
|
||||
await loadSecurityPolicy();
|
||||
await loadTlsSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
passwordForm.addEventListener("submit", updatePassword);
|
||||
prefsForm.addEventListener("submit", savePrefs);
|
||||
if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram);
|
||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||
|
||||
@@ -68,6 +68,10 @@ async function getCurrentUser() {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.canBuild) {
|
||||
document.getElementById("build-link")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (currentUser.role !== "admin") {
|
||||
alert("Access denied. Admin role required.");
|
||||
window.location.href = "/";
|
||||
@@ -149,6 +153,13 @@ function renderUsers() {
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
${getRoleBadge(user.role)}
|
||||
${user.role !== "admin" ? `
|
||||
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-colors ${user.can_build ? 'bg-green-900/30 text-green-300 border border-green-800 hover:bg-green-900/50' : 'bg-slate-700/30 text-slate-500 border border-slate-700 hover:bg-slate-700/50'}"
|
||||
data-action="toggle-build" data-user-id="${user.id}" data-username="${escapeHtml(user.username)}" data-can-build="${user.can_build ? 1 : 0}"
|
||||
title="${user.can_build ? 'Can build (click to revoke)' : 'Cannot build (click to grant)'}">
|
||||
<i class="fa-solid fa-hammer mr-1"></i>${user.can_build ? 'Build' : 'No Build'}
|
||||
</span>
|
||||
` : ''}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-slate-400">
|
||||
${formatDate(user.created_at)}
|
||||
@@ -234,6 +245,15 @@ function attachActionListeners() {
|
||||
}
|
||||
|
||||
const listener = (e) => {
|
||||
const toggleBuild = e.target.closest("[data-action='toggle-build']");
|
||||
if (toggleBuild) {
|
||||
const userId = parseInt(toggleBuild.dataset.userId);
|
||||
const username = toggleBuild.dataset.username;
|
||||
const canBuild = toggleBuild.dataset.canBuild === "1";
|
||||
toggleBuildPermission(userId, username, canBuild);
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest(".user-action-btn");
|
||||
if (!btn) return;
|
||||
|
||||
@@ -429,5 +449,29 @@ window.configureClientAccess = async function (userId, username, role) {
|
||||
window.location.href = `/user-client-access?${params.toString()}`;
|
||||
};
|
||||
|
||||
window.toggleBuildPermission = async function (userId, username, currentCanBuild) {
|
||||
const newVal = !currentCanBuild;
|
||||
if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${userId}/can-build`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ canBuild: newVal }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
await loadUsers();
|
||||
} else {
|
||||
alert(data.error || "Failed to update build permission");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Toggle build permission error:", err);
|
||||
alert("Network error. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
getCurrentUser();
|
||||
loadUsers();
|
||||
|
||||
@@ -104,6 +104,36 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Notification Settings</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
Receive Telegram notifications for clients you have access to.
|
||||
</p>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Your Telegram Chat ID</span>
|
||||
<input
|
||||
id="my-telegram-chat-id"
|
||||
type="text"
|
||||
placeholder="e.g. 123456789 (leave empty to disable)"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-slate-400">
|
||||
Send <code>/start</code> to the Telegram bot, then use <code>/id</code> or
|
||||
<a href="https://t.me/userinfobot" target="_blank" rel="noopener" class="text-sky-400 hover:underline">@userinfobot</a>
|
||||
to find your chat ID.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="save-my-telegram"
|
||||
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-brands fa-telegram mr-2"></i>Save Telegram Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Security Policy</h3>
|
||||
|
||||
Reference in New Issue
Block a user