diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java index 87fe03d..fbd1717 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java @@ -1,36 +1,19 @@ package dev.kruhlmann.imgfloat.controller; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.FORBIDDEN; -import static org.springframework.http.HttpStatus.NOT_FOUND; - import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.SettingsService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; -import java.io.IOException; -import java.util.Collection; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/settings") @@ -60,6 +43,7 @@ public class SettingsApiController { settingsService.logSettings("From: ", currentSettings); settingsService.logSettings("To: ", newSettings); - return ResponseEntity.ok().body(newSettings); + Settings savedSettings = settingsService.save(newSettings); + return ResponseEntity.ok().body(savedSettings); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/SystemAdministratorApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/SystemAdministratorApiController.java new file mode 100644 index 0000000..368efcd --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/SystemAdministratorApiController.java @@ -0,0 +1,84 @@ +package dev.kruhlmann.imgfloat.controller; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.SystemAdministratorService; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/system-administrators") +@SecurityRequirement(name = "administrator") +public class SystemAdministratorApiController { + + private static final Logger LOG = LoggerFactory.getLogger(SystemAdministratorApiController.class); + + private final SystemAdministratorService systemAdministratorService; + private final AuthorizationService authorizationService; + + public SystemAdministratorApiController( + SystemAdministratorService systemAdministratorService, + AuthorizationService authorizationService + ) { + this.systemAdministratorService = systemAdministratorService; + this.authorizationService = authorizationService; + } + + @GetMapping + public ResponseEntity> listSystemAdministrators(OAuth2AuthenticationToken oauthToken) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + return ResponseEntity.ok().body(systemAdministratorService.listSysadmins()); + } + + @PostMapping + public ResponseEntity> addSystemAdministrator( + @RequestBody SystemAdministratorRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + if (request == null || request.twitchUsername() == null || request.twitchUsername().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Username is required"); + } + String username = request.twitchUsername().trim(); + systemAdministratorService.addSysadmin(username); + LOG.info("System administrator added: {} (requested by {})", username, sessionUsername); + return ResponseEntity.ok().body(systemAdministratorService.listSysadmins()); + } + + @DeleteMapping("/{twitchUsername}") + public ResponseEntity> removeSystemAdministrator( + @PathVariable("twitchUsername") String twitchUsername, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + try { + systemAdministratorService.removeSysadmin(twitchUsername); + } catch (IllegalStateException e) { + throw new ResponseStatusException(BAD_REQUEST, e.getMessage(), e); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(NOT_FOUND, e.getMessage(), e); + } + LOG.info("System administrator removed: {} (requested by {})", twitchUsername, sessionUsername); + return ResponseEntity.ok().body(systemAdministratorService.listSysadmins()); + } + + private record SystemAdministratorRequest(String twitchUsername) {} +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 396b63b..cfc3eac 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -62,6 +62,7 @@ public class ViewController { model.addAttribute("username", sessionUsername); model.addAttribute("channel", sessionUsername); model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); + model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername)); addVersionAttributes(model); return "dashboard"; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java index ca51d65..1a0da0e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/SystemAdministratorRepository.java @@ -1,9 +1,11 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.SystemAdministrator; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface SystemAdministratorRepository extends JpaRepository { boolean existsByTwitchUsername(String twitchUsername); long deleteByTwitchUsername(String twitchUsername); + List findAllByOrderByTwitchUsernameAsc(); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java index 74a934b..c8f0ff9 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java @@ -3,7 +3,9 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; import jakarta.annotation.PostConstruct; +import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -80,6 +82,14 @@ public class SystemAdministratorService { return repo.existsByTwitchUsername(normalize(twitchUsername)); } + public List listSysadmins() { + return repo + .findAllByOrderByTwitchUsernameAsc() + .stream() + .map(SystemAdministrator::getTwitchUsername) + .collect(Collectors.toList()); + } + private String normalize(String username) { return username.trim().toLowerCase(Locale.ROOT); } diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index 361bd67..5a3424e 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -14,8 +14,11 @@ const statCanvasSizeElement = document.getElementById("stat-canvas-size"); const statPlaybackRangeElement = document.getElementById("stat-playback-range"); const statAudioRangeElement = document.getElementById("stat-audio-range"); const statVolumeRangeElement = document.getElementById("stat-volume-range"); +const sysadminListElement = document.getElementById("sysadmin-list"); +const sysadminInputElement = document.getElementById("new-sysadmin"); +const addSysadminButtonElement = document.getElementById("add-sysadmin-button"); -const currentSettings = JSON.parse(serverRenderedSettings); +let currentSettings = JSON.parse(serverRenderedSettings); let userSettings = { ...currentSettings }; function jsonEquals(a, b) { @@ -133,6 +136,100 @@ function submitSettingsForm() { }); } +function renderSystemAdministrators(admins) { + sysadminListElement.innerHTML = ""; + if (!admins || admins.length === 0) { + const empty = document.createElement("li"); + empty.classList.add("stacked-list-item"); + empty.innerHTML = '

No system administrators found.

'; + sysadminListElement.appendChild(empty); + return; + } + + admins.forEach((admin) => { + const listItem = document.createElement("li"); + listItem.classList.add("stacked-list-item"); + + const text = document.createElement("div"); + text.innerHTML = `

${admin}

System admin access

`; + + const button = document.createElement("button"); + button.classList.add("button", "secondary"); + button.type = "button"; + button.textContent = "Remove"; + button.addEventListener("click", () => removeSystemAdministrator(admin)); + + listItem.appendChild(text); + listItem.appendChild(button); + sysadminListElement.appendChild(listItem); + }); +} + +function loadSystemAdministrators() { + return fetch("/api/system-administrators") + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load system admins"); + } + return r.json(); + }) + .then((admins) => { + renderSystemAdministrators(admins); + }) + .catch((error) => { + console.error(error); + showToast("Unable to load system admins", "error"); + }); +} + +function addSystemAdministrator() { + const username = sysadminInputElement.value.trim(); + if (!username) { + showToast("Enter a Twitch username", "warning"); + return; + } + fetch("/api/system-administrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ twitchUsername: username }), + }) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to add system admin"); + } + return r.json(); + }) + .then((admins) => { + sysadminInputElement.value = ""; + renderSystemAdministrators(admins); + showToast("System admin added", "success"); + }) + .catch((error) => { + console.error(error); + showToast("Unable to add system admin", "error"); + }); +} + +function removeSystemAdministrator(username) { + fetch(`/api/system-administrators/${encodeURIComponent(username)}`, { method: "DELETE" }) + .then((r) => { + if (!r.ok) { + return r.text().then((text) => { + throw new Error(text || "Failed to remove system admin"); + }); + } + return r.json(); + }) + .then((admins) => { + renderSystemAdministrators(admins); + showToast("System admin removed", "success"); + }) + .catch((error) => { + console.error(error); + showToast("Unable to remove system admin", "error"); + }); +} + formElement.querySelectorAll("input").forEach((input) => { input.addEventListener("input", () => { loadUserSettingsFromDom(); @@ -140,6 +237,14 @@ formElement.querySelectorAll("input").forEach((input) => { }); }); +addSysadminButtonElement.addEventListener("click", () => addSystemAdministrator()); +sysadminInputElement.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addSystemAdministrator(); + } +}); + formElement.addEventListener("submit", (event) => { event.preventDefault(); submitSettingsForm(); @@ -148,3 +253,4 @@ formElement.addEventListener("submit", (event) => { setFormSettings(currentSettings); updateStatCards(currentSettings); updateSubmitButtonDisabledState(); +loadSystemAdministrators(); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 436e7a6..751cfa9 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -31,6 +31,7 @@
Open broadcast overlay Open admin console + Application settings Browse channels
diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 77c03bc..7707837 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -236,6 +236,27 @@
+ +
+
+

Access

+

System administrators

+

+ Invite teammates who can manage global defaults and other system-wide actions. +

+
+
+ + +
+
+
+

Current system admins

+

These users can access the settings page and APIs.

+
+
    +
    +