Add system admin settings

This commit is contained in:
2026-01-09 20:23:12 +01:00
parent f14dc9d783
commit cc9eea9c08
8 changed files with 228 additions and 19 deletions

View File

@@ -1,36 +1,19 @@
package dev.kruhlmann.imgfloat.controller; 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.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.SettingsService; import dev.kruhlmann.imgfloat.service.SettingsService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; 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.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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/settings") @RequestMapping("/api/settings")
@@ -60,6 +43,7 @@ public class SettingsApiController {
settingsService.logSettings("From: ", currentSettings); settingsService.logSettings("From: ", currentSettings);
settingsService.logSettings("To: ", newSettings); settingsService.logSettings("To: ", newSettings);
return ResponseEntity.ok().body(newSettings); Settings savedSettings = settingsService.save(newSettings);
return ResponseEntity.ok().body(savedSettings);
} }
} }

View File

@@ -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<List<String>> listSystemAdministrators(OAuth2AuthenticationToken oauthToken) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
return ResponseEntity.ok().body(systemAdministratorService.listSysadmins());
}
@PostMapping
public ResponseEntity<List<String>> 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<List<String>> 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) {}
}

View File

@@ -62,6 +62,7 @@ public class ViewController {
model.addAttribute("username", sessionUsername); model.addAttribute("username", sessionUsername);
model.addAttribute("channel", sessionUsername); model.addAttribute("channel", sessionUsername);
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername));
model.addAttribute("isSystemAdmin", authorizationService.userIsSystemAdministrator(sessionUsername));
addVersionAttributes(model); addVersionAttributes(model);
return "dashboard"; return "dashboard";
} }

View File

@@ -1,9 +1,11 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.model.SystemAdministrator;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface SystemAdministratorRepository extends JpaRepository<SystemAdministrator, String> { public interface SystemAdministratorRepository extends JpaRepository<SystemAdministrator, String> {
boolean existsByTwitchUsername(String twitchUsername); boolean existsByTwitchUsername(String twitchUsername);
long deleteByTwitchUsername(String twitchUsername); long deleteByTwitchUsername(String twitchUsername);
List<SystemAdministrator> findAllByOrderByTwitchUsernameAsc();
} }

View File

@@ -3,7 +3,9 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.model.SystemAdministrator;
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -80,6 +82,14 @@ public class SystemAdministratorService {
return repo.existsByTwitchUsername(normalize(twitchUsername)); return repo.existsByTwitchUsername(normalize(twitchUsername));
} }
public List<String> listSysadmins() {
return repo
.findAllByOrderByTwitchUsernameAsc()
.stream()
.map(SystemAdministrator::getTwitchUsername)
.collect(Collectors.toList());
}
private String normalize(String username) { private String normalize(String username) {
return username.trim().toLowerCase(Locale.ROOT); return username.trim().toLowerCase(Locale.ROOT);
} }

View File

@@ -14,8 +14,11 @@ const statCanvasSizeElement = document.getElementById("stat-canvas-size");
const statPlaybackRangeElement = document.getElementById("stat-playback-range"); const statPlaybackRangeElement = document.getElementById("stat-playback-range");
const statAudioRangeElement = document.getElementById("stat-audio-range"); const statAudioRangeElement = document.getElementById("stat-audio-range");
const statVolumeRangeElement = document.getElementById("stat-volume-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 }; let userSettings = { ...currentSettings };
function jsonEquals(a, b) { 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 = '<p class="muted">No system administrators found.</p>';
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 = `<p class="list-title">${admin}</p><p class="muted">System admin access</p>`;
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) => { formElement.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", () => { input.addEventListener("input", () => {
loadUserSettingsFromDom(); 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) => { formElement.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
submitSettingsForm(); submitSettingsForm();
@@ -148,3 +253,4 @@ formElement.addEventListener("submit", (event) => {
setFormSettings(currentSettings); setFormSettings(currentSettings);
updateStatCards(currentSettings); updateStatCards(currentSettings);
updateSubmitButtonDisabledState(); updateSubmitButtonDisabledState();
loadSystemAdministrators();

View File

@@ -31,6 +31,7 @@
<div class="panel-actions"> <div class="panel-actions">
<a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a> <a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a>
<a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a> <a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a>
<a class="button ghost block" th:if="${isSystemAdmin}" href="/settings">Application settings</a>
<a class="button ghost block" href="/channels">Browse channels</a> <a class="button ghost block" href="/channels">Browse channels</a>
<form class="block" th:action="@{/logout}" method="post"> <form class="block" th:action="@{/logout}" method="post">
<button class="secondary block" type="submit">Logout</button> <button class="secondary block" type="submit">Logout</button>

View File

@@ -236,6 +236,27 @@
</button> </button>
</div> </div>
</form> </form>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Access</p>
<h3>System administrators</h3>
<p class="muted tiny">
Invite teammates who can manage global defaults and other system-wide actions.
</p>
</div>
<div class="inline-form">
<input id="new-sysadmin" placeholder="Twitch username" />
<button id="add-sysadmin-button" class="button" type="button">Add system admin</button>
</div>
<div class="card-section">
<div class="section-header">
<h4 class="list-title">Current system admins</h4>
<p class="muted">These users can access the settings page and APIs.</p>
</div>
<ul id="sysadmin-list" class="stacked-list"></ul>
</div>
</div>
</section> </section>
<aside class="settings-sidebar"> <aside class="settings-sidebar">