diff --git a/README.md b/README.md index afe5603..1044fac 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Optional: |----------|-------------|---------------| | `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ | | `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts | +| `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Allow sysadmins to manage any channel without being listed as a channel admin | true | | `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch | | `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to allow decryption after key rotation (oldest last) | oldKey1==,oldKey2== | diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java index 0003961..d589a69 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java @@ -7,6 +7,7 @@ import static org.springframework.http.HttpStatus.UNAUTHORIZED; import dev.kruhlmann.imgfloat.util.LogSanitizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -17,13 +18,16 @@ public class AuthorizationService { private final ChannelDirectoryService channelDirectoryService; private final SystemAdministratorService systemAdministratorService; + private final boolean sysadminChannelAccessEnabled; public AuthorizationService( ChannelDirectoryService channelDirectoryService, - SystemAdministratorService systemAdministratorService + SystemAdministratorService systemAdministratorService, + @Value("${IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED:true}") boolean sysadminChannelAccessEnabled ) { this.channelDirectoryService = channelDirectoryService; this.systemAdministratorService = systemAdministratorService; + this.sysadminChannelAccessEnabled = sysadminChannelAccessEnabled; } public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) { @@ -98,7 +102,9 @@ public class AuthorizationService { public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) { return ( - userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(broadcaster, sessionUser) + userIsBroadcaster(sessionUser, broadcaster) + || userIsChannelAdminForBroadcaster(broadcaster, sessionUser) + || (sysadminChannelAccessEnabled && userIsSystemAdministrator(sessionUser)) ); } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index bd7d4d2..a825db9 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -978,6 +978,14 @@ button:disabled:hover { flex-wrap: wrap; } +.field-stack { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 220px; +} + .inline-form input { flex: 1; min-width: 220px; @@ -988,6 +996,11 @@ button:disabled:hover { color: #e2e8f0; } +.form-helper { + font-size: 12px; + color: #94a3b8; +} + .card-section { margin-top: 16px; display: flex; @@ -2037,6 +2050,14 @@ button:disabled:hover { border: 1px solid #1f2937; } +.stacked-list-item.empty { + justify-content: center; + color: #94a3b8; + font-size: 14px; + border-style: dashed; + background: rgba(15, 23, 42, 0.5); +} + .stacked-list-item .list-title { margin: 0; font-weight: 700; diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 3cd99b4..fad6b86 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -1,3 +1,16 @@ +const elements = { + adminList: document.getElementById("admin-list"), + suggestionList: document.getElementById("admin-suggestions"), + adminInput: document.getElementById("new-admin"), + addAdminButton: document.getElementById("add-admin-btn"), + canvasWidth: document.getElementById("canvas-width"), + canvasHeight: document.getElementById("canvas-height"), + canvasStatus: document.getElementById("canvas-status"), + canvasSaveButton: document.getElementById("save-canvas-btn"), +}; + +const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`; + function buildIdentity(admin) { const identity = document.createElement("div"); identity.className = "identity-row"; @@ -29,13 +42,13 @@ function buildIdentity(admin) { } function renderAdmins(list) { - const adminList = document.getElementById("admin-list"); - if (!adminList) return; - adminList.innerHTML = ""; + if (!elements.adminList) return; + elements.adminList.innerHTML = ""; if (!list || list.length === 0) { const empty = document.createElement("li"); - empty.textContent = "No channel admins yet"; - adminList.appendChild(empty); + empty.className = "stacked-list-item empty"; + empty.textContent = "No channel admins yet."; + elements.adminList.appendChild(empty); return; } @@ -56,20 +69,19 @@ function renderAdmins(list) { actions.appendChild(removeBtn); li.appendChild(actions); - adminList.appendChild(li); + elements.adminList.appendChild(li); }); } function renderSuggestedAdmins(list) { - const suggestionList = document.getElementById("admin-suggestions"); - if (!suggestionList) return; + if (!elements.suggestionList) return; - suggestionList.innerHTML = ""; + elements.suggestionList.innerHTML = ""; if (!list || list.length === 0) { const empty = document.createElement("li"); - empty.className = "stacked-list-item"; - empty.textContent = "No moderator suggestions right now"; - suggestionList.appendChild(empty); + empty.className = "stacked-list-item empty"; + empty.textContent = "No moderator suggestions right now."; + elements.suggestionList.appendChild(empty); return; } @@ -90,139 +102,153 @@ function renderSuggestedAdmins(list) { actions.appendChild(addBtn); li.appendChild(actions); - suggestionList.appendChild(li); + elements.suggestionList.appendChild(li); }); } -function fetchSuggestedAdmins() { - fetch(`/api/channels/${broadcaster}/admins/suggestions`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load admin suggestions"); - } - return r.json(); - }) - .then(renderSuggestedAdmins) - .catch(() => { - renderSuggestedAdmins([]); - }); +function normalizeUsername(value) { + return (value || "").trim().replace(/^@+/, ""); } -function fetchAdmins() { - fetch(`/api/channels/${broadcaster}/admins`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load admins"); - } - return r.json(); - }) - .then(renderAdmins) - .catch(() => { - renderAdmins([]); - showToast("Unable to load admins right now. Please try again.", "error"); - }); +function setButtonBusy(button, isBusy, busyLabel) { + if (!button) return; + if (!button.dataset.defaultLabel) { + button.dataset.defaultLabel = button.textContent; + } + button.disabled = isBusy; + if (busyLabel) { + button.textContent = isBusy ? busyLabel : button.dataset.defaultLabel; + } } -function removeAdmin(username) { +async function fetchJson(path, options = {}, errorMessage = "Request failed") { + const response = await fetch(`${apiBase}${path}`, options); + if (!response.ok) { + throw new Error(errorMessage); + } + return response.json(); +} + +async function fetchSuggestedAdmins() { + try { + const data = await fetchJson("/admins/suggestions", {}, "Failed to load admin suggestions"); + renderSuggestedAdmins(data); + } catch (error) { + renderSuggestedAdmins([]); + } +} + +async function fetchAdmins() { + try { + const data = await fetchJson("/admins", {}, "Failed to load admins"); + renderAdmins(data); + } catch (error) { + renderAdmins([]); + showToast("Unable to load admins right now. Please try again.", "error"); + } +} + +async function removeAdmin(username) { if (!username) return; - fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { - method: "DELETE", - }) - .then((response) => { - if (!response.ok) { - throw new Error(); - } - fetchAdmins(); - fetchSuggestedAdmins(); - }) - .catch(() => { - showToast("Failed to remove admin. Please retry.", "error"); - }); + try { + const response = await fetch( + `${apiBase}/admins/${encodeURIComponent(username)}`, + { method: "DELETE" }, + ); + if (!response.ok) { + throw new Error("Remove admin failed"); + } + await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]); + } catch (error) { + showToast("Failed to remove admin. Please retry.", "error"); + } } -function addAdmin(usernameFromAction) { - const input = document.getElementById("new-admin"); - const username = (usernameFromAction || input?.value || "").trim(); +async function addAdmin(usernameFromAction) { + const username = normalizeUsername(usernameFromAction || elements.adminInput?.value); if (!username) { showToast("Enter a Twitch username to add as an admin.", "info"); return; } - fetch(`/api/channels/${broadcaster}/admins`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }) - .then((response) => { - if (!response.ok) { - throw new Error("Add admin failed"); - } - if (input) { - input.value = ""; - } - showToast(`Added @${username} as an admin.`, "success"); - fetchAdmins(); - fetchSuggestedAdmins(); - }) - .catch(() => showToast("Unable to add admin right now. Please try again.", "error")); + setButtonBusy(elements.addAdminButton, true, "Adding..."); + try { + await fetchJson( + "/admins", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }, + "Add admin failed", + ); + if (elements.adminInput) { + elements.adminInput.value = ""; + } + showToast(`Added @${username} as an admin.`, "success"); + await Promise.all([fetchAdmins(), fetchSuggestedAdmins()]); + } catch (error) { + showToast("Unable to add admin right now. Please try again.", "error"); + } finally { + setButtonBusy(elements.addAdminButton, false, "Adding..."); + } } function renderCanvasSettings(settings) { - const widthInput = document.getElementById("canvas-width"); - const heightInput = document.getElementById("canvas-height"); - if (widthInput) widthInput.value = Math.round(settings.width); - if (heightInput) heightInput.value = Math.round(settings.height); + if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width); + if (elements.canvasHeight) elements.canvasHeight.value = Math.round(settings.height); } -function fetchCanvasSettings() { - fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas settings"); - } - return r.json(); - }) - .then(renderCanvasSettings) - .catch(() => { - renderCanvasSettings({ width: 1920, height: 1080 }); - showToast("Using default canvas size. Unable to load saved settings.", "warning"); - }); +async function fetchCanvasSettings() { + try { + const data = await fetchJson("/canvas", {}, "Failed to load canvas settings"); + renderCanvasSettings(data); + } catch (error) { + renderCanvasSettings({ width: 1920, height: 1080 }); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + } } -function saveCanvasSettings() { - const widthInput = document.getElementById("canvas-width"); - const heightInput = document.getElementById("canvas-height"); - const status = document.getElementById("canvas-status"); - const width = parseFloat(widthInput?.value) || 0; - const height = parseFloat(heightInput?.value) || 0; +async function saveCanvasSettings() { + const width = parseFloat(elements.canvasWidth?.value) || 0; + const height = parseFloat(elements.canvasHeight?.value) || 0; if (width <= 0 || height <= 0) { showToast("Please enter a valid width and height.", "info"); return; } - if (status) status.textContent = "Saving..."; - fetch(`/api/channels/${broadcaster}/canvas`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ width, height }), - }) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to save canvas"); - } - return r.json(); - }) - .then((settings) => { - renderCanvasSettings(settings); - if (status) status.textContent = "Saved."; - showToast("Canvas size saved successfully.", "success"); - setTimeout(() => { - if (status) status.textContent = ""; - }, 2000); - }) - .catch(() => { - if (status) status.textContent = "Unable to save right now."; - showToast("Unable to save canvas size. Please retry.", "error"); - }); + if (elements.canvasStatus) elements.canvasStatus.textContent = "Saving..."; + setButtonBusy(elements.canvasSaveButton, true, "Saving..."); + try { + const settings = await fetchJson( + "/canvas", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ width, height }), + }, + "Failed to save canvas", + ); + renderCanvasSettings(settings); + if (elements.canvasStatus) elements.canvasStatus.textContent = "Saved."; + showToast("Canvas size saved successfully.", "success"); + setTimeout(() => { + if (elements.canvasStatus) elements.canvasStatus.textContent = ""; + }, 2000); + } catch (error) { + if (elements.canvasStatus) elements.canvasStatus.textContent = "Unable to save right now."; + showToast("Unable to save canvas size. Please retry.", "error"); + } finally { + setButtonBusy(elements.canvasSaveButton, false, "Saving..."); + } +} + +if (elements.adminInput) { + elements.adminInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addAdmin(); + } + }); } fetchAdmins(); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 751cfa9..b099a51 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -54,8 +54,8 @@