mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Update settings
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user