mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Add login agreement disclaimer feature (#6766)
This commit is contained in:
@@ -514,6 +514,14 @@ public class ApplicationProperties {
|
||||
private String accessibilityStatement;
|
||||
private String cookiePolicy;
|
||||
private String impressum;
|
||||
private LoginAgreement loginAgreement = new LoginAgreement();
|
||||
|
||||
@Data
|
||||
public static class LoginAgreement {
|
||||
private boolean enabled = false;
|
||||
private boolean showInAnonymousMode = true;
|
||||
private String fallbackText = "";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
|
||||
// enable/visibility come from the legal.loginAgreement settings.
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginAgreementService {
|
||||
|
||||
// Locale codes only: rejects path separators and dots so the value can never escape the
|
||||
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
|
||||
private static final Pattern LOCALE_PATTERN =
|
||||
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
|
||||
|
||||
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
|
||||
// from recursing far enough to overflow the stack on a hostile over-length input.
|
||||
private static final int MAX_LOCALE_LENGTH = 35;
|
||||
|
||||
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
|
||||
// wholesale into heap on every public request.
|
||||
private static final long MAX_FILE_BYTES = 256 * 1024;
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public LoginAgreementService(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return config().isEnabled();
|
||||
}
|
||||
|
||||
public boolean isShowInAnonymousMode() {
|
||||
return config().isShowInAnonymousMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the markdown to show for the requested language, falling back through the base
|
||||
* language, the configured default locale (and its base), then the configured fallbackText.
|
||||
* Returns an empty string when nothing is configured.
|
||||
*/
|
||||
public String resolveContent(String requestedLang) {
|
||||
List<String> candidates = new ArrayList<>();
|
||||
addLocaleCandidates(candidates, requestedLang);
|
||||
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
for (String candidate : candidates) {
|
||||
String content = readFileIfExists(candidate);
|
||||
if (content != null && !content.isBlank()) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
String fallback = config().getFallbackText();
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
|
||||
*/
|
||||
public String readRawForLocale(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
String content = readFileIfExists(locale);
|
||||
return content == null ? "" : content;
|
||||
}
|
||||
|
||||
/** Admin write. Blank content deletes the file so it falls back cleanly. */
|
||||
public void writeForLocale(String locale, String content) throws IOException {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("Invalid locale: " + locale);
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
Files.deleteIfExists(file);
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(file.getParent());
|
||||
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
|
||||
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
|
||||
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
|
||||
try {
|
||||
Files.writeString(tmp, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(
|
||||
tmp,
|
||||
file,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locales that currently have a markdown file, for the admin editor. */
|
||||
public Set<String> listLocalesWithContent() {
|
||||
Set<String> result = new TreeSet<>();
|
||||
Path dir = disclaimerDir();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return result;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> name.endsWith(".md"))
|
||||
.map(name -> name.substring(0, name.length() - ".md".length()))
|
||||
.filter(this::isValidLocale)
|
||||
.forEach(result::add);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed listing login agreement files", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ApplicationProperties.Legal.LoginAgreement config() {
|
||||
return applicationProperties.getLegal().getLoginAgreement();
|
||||
}
|
||||
|
||||
private Path disclaimerDir() {
|
||||
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
|
||||
}
|
||||
|
||||
private void addLocaleCandidates(List<String> out, String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return;
|
||||
}
|
||||
if (!out.contains(locale)) {
|
||||
out.add(locale);
|
||||
}
|
||||
String base = locale.split("[_-]", 2)[0];
|
||||
if (!base.equals(locale) && !out.contains(base)) {
|
||||
out.add(base);
|
||||
}
|
||||
}
|
||||
|
||||
private String readFileIfExists(String locale) {
|
||||
Path file = resolveLocaleFile(locale);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
|
||||
// planted symlink can't expose files outside the disclaimer dir via the public read.
|
||||
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.size(file) > MAX_FILE_BYTES) {
|
||||
log.warn(
|
||||
"Login agreement file for locale {} exceeds {} bytes; ignoring",
|
||||
locale,
|
||||
MAX_FILE_BYTES);
|
||||
return null;
|
||||
}
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed reading login agreement file for locale {}", locale, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path resolveLocaleFile(String locale) {
|
||||
if (!isValidLocale(locale)) {
|
||||
return null;
|
||||
}
|
||||
Path dir = disclaimerDir();
|
||||
Path file = dir.resolve(locale + ".md").normalize();
|
||||
// Defence in depth: the regex already blocks separators, but confirm containment.
|
||||
if (!file.startsWith(dir)) {
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private boolean isValidLocale(String locale) {
|
||||
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
|
||||
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
|
||||
return locale != null
|
||||
&& locale.length() <= MAX_LOCALE_LENGTH
|
||||
&& LOCALE_PATTERN.matcher(locale).matches();
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
|
||||
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
|
||||
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
|
||||
* isolated.
|
||||
*/
|
||||
class LoginAgreementServiceTest {
|
||||
|
||||
@TempDir Path customFilesDir;
|
||||
|
||||
private ApplicationProperties properties;
|
||||
private ApplicationProperties.Legal.LoginAgreement config;
|
||||
private LoginAgreementService service;
|
||||
private Path disclaimerDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ApplicationProperties();
|
||||
config = properties.getLegal().getLoginAgreement();
|
||||
service = new LoginAgreementService(properties);
|
||||
disclaimerDir = customFilesDir.resolve("disclaimer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
|
||||
*/
|
||||
private void withMockedPath(Runnable action) {
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getCustomFilesPath)
|
||||
.thenReturn(customFilesDir.toString());
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeFile(String locale, String content) throws IOException {
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsReflectConfig() {
|
||||
config.setEnabled(true);
|
||||
config.setShowInAnonymousMode(false);
|
||||
assertTrue(service.isEnabled());
|
||||
assertFalse(service.isShowInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsExactLocaleFile() throws IOException {
|
||||
writeFile("fr-FR", "# Avis");
|
||||
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToBaseLanguage() throws IOException {
|
||||
// Only a language-only file exists; a region-specific request should fall back to it.
|
||||
writeFile("de", "# Hinweis");
|
||||
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToDefaultLocale() throws IOException {
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
writeFile("en-GB", "# Notice");
|
||||
// No file for the requested locale -> falls through to the configured default locale.
|
||||
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentFallsBackToFallbackTextWhenNoFile() {
|
||||
config.setFallbackText("# Fallback");
|
||||
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentReturnsEmptyWhenNothingConfigured() {
|
||||
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
|
||||
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
|
||||
Files.writeString(
|
||||
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
|
||||
config.setFallbackText("safe");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertEquals("safe", service.resolveContent("../secret"));
|
||||
assertEquals("safe", service.resolveContent("..%2Fsecret"));
|
||||
assertEquals("safe", service.resolveContent("/etc/passwd"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertNull(service.readRawForLocale("../secret"));
|
||||
assertNull(service.readRawForLocale("en/GB"));
|
||||
assertNull(service.readRawForLocale("C:\\x"));
|
||||
assertNull(service.readRawForLocale(null));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void readRawReturnsEmptyForValidButAbsentLocale() {
|
||||
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlongLocaleIsRejectedWithoutStackOverflow() {
|
||||
// Guards against the regex-recursion stack overflow on unbounded input.
|
||||
String hostile = "en" + "-ab".repeat(4000);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
|
||||
assertNull(service.readRawForLocale(hostile));
|
||||
assertDoesNotThrow(() -> service.resolveContent(hostile));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeThenReadRoundTrips() throws IOException {
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
|
||||
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBlankDeletesFile() throws IOException {
|
||||
writeFile("fr-FR", "# Bonjour");
|
||||
withMockedPath(
|
||||
() -> {
|
||||
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
|
||||
assertEquals("", service.readRawForLocale("fr-FR"));
|
||||
});
|
||||
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeRejectsInvalidLocale() {
|
||||
withMockedPath(
|
||||
() ->
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> service.writeForLocale("../escape", "x")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
|
||||
writeFile("en-GB", "a");
|
||||
writeFile("fr-FR", "b");
|
||||
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
|
||||
withMockedPath(
|
||||
() -> {
|
||||
var locales = service.listLocalesWithContent();
|
||||
assertTrue(locales.contains("en-GB"));
|
||||
assertTrue(locales.contains("fr-FR"));
|
||||
assertEquals(2, locales.size());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedFileIsIgnored() throws IOException {
|
||||
// Files beyond the read cap are skipped rather than loaded into heap.
|
||||
byte[] big = new byte[300 * 1024];
|
||||
java.util.Arrays.fill(big, (byte) 'x');
|
||||
Files.createDirectories(disclaimerDir);
|
||||
Files.write(disclaimerDir.resolve("en-GB.md"), big);
|
||||
config.setFallbackText("small-fallback");
|
||||
properties.getSystem().setDefaultLocale("en-GB");
|
||||
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
|
||||
* it requires authentication when login is enabled (the modal is shown after login, never on the
|
||||
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
|
||||
* from disk, so admin edits take effect on the next login without a restart.
|
||||
*/
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
public class LoginDisclaimerController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping("/login-disclaimer")
|
||||
@Operation(
|
||||
summary = "Get the login agreement/disclaimer",
|
||||
description =
|
||||
"Returns whether the login agreement is enabled and, if so, the markdown to"
|
||||
+ " display for the requested language.")
|
||||
public LoginDisclaimerResponse getLoginDisclaimer(
|
||||
@RequestParam(name = "lang", required = false) String lang) {
|
||||
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
|
||||
if (!loginAgreementService.isEnabled()) {
|
||||
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
|
||||
}
|
||||
String content = loginAgreementService.resolveContent(lang);
|
||||
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
|
||||
// report disabled so clients don't try to render an empty agreement.
|
||||
boolean hasContent = content != null && !content.isBlank();
|
||||
return new LoginDisclaimerResponse(
|
||||
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
|
||||
}
|
||||
|
||||
public record LoginDisclaimerResponse(
|
||||
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
|
||||
}
|
||||
@@ -165,6 +165,10 @@ legal:
|
||||
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
loginAgreement:
|
||||
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
|
||||
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
|
||||
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
|
||||
|
||||
system:
|
||||
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse;
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginDisclaimerControllerTest {
|
||||
|
||||
@Mock LoginAgreementService loginAgreementService;
|
||||
|
||||
@InjectMocks LoginDisclaimerController controller;
|
||||
|
||||
@Test
|
||||
void disabledReturnsEmptyContent() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(false);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
assertTrue(resp.showInAnonymousMode());
|
||||
assertEquals("markdown", resp.format());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledWithContentReturnsIt() {
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false);
|
||||
when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR");
|
||||
|
||||
assertTrue(resp.enabled());
|
||||
assertEquals("# Avis", resp.content());
|
||||
assertFalse(resp.showInAnonymousMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledButBlankContentReportsDisabled() {
|
||||
// No file for any candidate locale and no fallbackText -> report disabled so clients
|
||||
// don't render an empty agreement.
|
||||
when(loginAgreementService.isEnabled()).thenReturn(true);
|
||||
when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true);
|
||||
when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" ");
|
||||
|
||||
LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP");
|
||||
|
||||
assertFalse(resp.enabled());
|
||||
assertEquals("", resp.content());
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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 io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
|
||||
/**
|
||||
* Admin editing of the per-language login agreement markdown files
|
||||
* (customFiles/disclaimer/<locale>.md). The enable/visibility flags are managed through the
|
||||
* normal admin settings endpoints; only the live-edited text is handled here.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/login-agreement")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Admin Settings", description = "Login agreement text management")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class AdminLoginAgreementController {
|
||||
|
||||
private final LoginAgreementService loginAgreementService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List locales that currently have login agreement text")
|
||||
public Set<String> listLocales() {
|
||||
return loginAgreementService.listLocalesWithContent();
|
||||
}
|
||||
|
||||
@GetMapping("/{locale}")
|
||||
@Operation(summary = "Read the login agreement markdown for a locale")
|
||||
public ResponseEntity<Map<String, String>> read(@PathVariable String locale) {
|
||||
String content = loginAgreementService.readRawForLocale(locale);
|
||||
if (content == null) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("locale", locale, "content", content));
|
||||
}
|
||||
|
||||
@PutMapping("/{locale}")
|
||||
@Operation(summary = "Write the login agreement markdown for a locale (blank clears it)")
|
||||
public ResponseEntity<Void> write(
|
||||
@PathVariable String locale, @RequestBody DisclaimerContentRequest request) {
|
||||
try {
|
||||
loginAgreementService.writeForLocale(
|
||||
locale, request == null ? null : request.content());
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (IOException e) {
|
||||
log.error("Failed writing login agreement for locale {}", locale, e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
public record DisclaimerContentRequest(String content) {}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.common.service.LoginAgreementService;
|
||||
import stirling.software.proprietary.security.controller.api.AdminLoginAgreementController.DisclaimerContentRequest;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminLoginAgreementControllerTest {
|
||||
|
||||
@Mock LoginAgreementService loginAgreementService;
|
||||
|
||||
@InjectMocks AdminLoginAgreementController controller;
|
||||
|
||||
@Test
|
||||
void listDelegatesToService() {
|
||||
when(loginAgreementService.listLocalesWithContent()).thenReturn(Set.of("en-GB", "fr-FR"));
|
||||
assertEquals(Set.of("en-GB", "fr-FR"), controller.listLocales());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readReturnsContentForValidLocale() {
|
||||
when(loginAgreementService.readRawForLocale("fr-FR")).thenReturn("# Avis");
|
||||
ResponseEntity<?> resp = controller.read("fr-FR");
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readReturnsBadRequestForInvalidLocale() {
|
||||
// Service returns null for an invalid locale.
|
||||
when(loginAgreementService.readRawForLocale("../escape")).thenReturn(null);
|
||||
ResponseEntity<?> resp = controller.read("../escape");
|
||||
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeDelegatesAndReturnsNoContent() throws IOException {
|
||||
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("# Hi"));
|
||||
assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode());
|
||||
verify(loginAgreementService).writeForLocale("fr-FR", "# Hi");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeReturnsBadRequestOnInvalidLocale() throws IOException {
|
||||
doThrow(new IllegalArgumentException("Invalid locale"))
|
||||
.when(loginAgreementService)
|
||||
.writeForLocale(eq("../escape"), eq("x"));
|
||||
ResponseEntity<Void> resp =
|
||||
controller.write("../escape", new DisclaimerContentRequest("x"));
|
||||
assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeReturnsServerErrorOnIoException() throws IOException {
|
||||
doThrow(new IOException("disk full"))
|
||||
.when(loginAgreementService)
|
||||
.writeForLocale(eq("fr-FR"), eq("x"));
|
||||
ResponseEntity<Void> resp = controller.write("fr-FR", new DisclaimerContentRequest("x"));
|
||||
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ info = "Informationen"
|
||||
invalidUndoData = "Rückgängig nicht möglich: ungültige Vorgangsdaten"
|
||||
keepWorking = "Weiterarbeiten"
|
||||
loading = "Laden..."
|
||||
loginAgreementAccept = "Akzeptieren"
|
||||
loginAgreementDecline = "Ablehnen"
|
||||
loginAgreementProvider = "Dieser Hinweis stammt von Ihrem Administrator, nicht von Stirling PDF Inc."
|
||||
loginAgreementTitle = "Anmeldevereinbarung"
|
||||
logOut = "Abmelden"
|
||||
marginTooltip = "Abstand zwischen der Seitenzahl und dem Seitenrand."
|
||||
moreOptions = "Weitere Optionen"
|
||||
|
||||
@@ -55,6 +55,10 @@ info = "Info"
|
||||
invalidUndoData = "Cannot undo: invalid operation data"
|
||||
keepWorking = "Keep Working"
|
||||
loading = "Loading..."
|
||||
loginAgreementAccept = "Accept"
|
||||
loginAgreementDecline = "Decline"
|
||||
loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc."
|
||||
loginAgreementTitle = "Login Agreement"
|
||||
logOut = "Log out"
|
||||
marginTooltip = "Distance between the page number and the edge of the page."
|
||||
moreOptions = "More Options"
|
||||
|
||||
@@ -55,6 +55,10 @@ info = "Info"
|
||||
invalidUndoData = "Cannot undo: invalid operation data"
|
||||
keepWorking = "Keep Working"
|
||||
loading = "Loading..."
|
||||
loginAgreementAccept = "Accept"
|
||||
loginAgreementDecline = "Decline"
|
||||
loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc."
|
||||
loginAgreementTitle = "Login Agreement"
|
||||
logOut = "Log out"
|
||||
marginTooltip = "Distance between the page number and the edge of the page."
|
||||
moreOptions = "More Options"
|
||||
@@ -1072,6 +1076,30 @@ title = "Legal Responsibility Warning"
|
||||
description = "URL or filename to impressum (required in some jurisdictions)"
|
||||
label = "Impressum"
|
||||
|
||||
[admin.settings.legal.loginAgreement]
|
||||
defaultLocaleHint = "— used as the fallback when a language has no file."
|
||||
defaultLocaleLink = "Configure the default locale"
|
||||
description = "Show a disclaimer users must accept after logging in. The text follows each user's language."
|
||||
discardConfirm = "You have unsaved changes that will be lost. Switch language anyway?"
|
||||
editLabel = "Markdown"
|
||||
emptyPreview = "Nothing to preview yet."
|
||||
language = "Language"
|
||||
languageHelp = "Each language has its own file. If a user's language has no file, the agreement falls back to the default locale's file, then to the fallback text."
|
||||
loadError = "Failed to load the agreement for {{locale}}. Switch language and back to retry."
|
||||
previewLabel = "Preview"
|
||||
restartNote = "Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately."
|
||||
save = "Save text"
|
||||
saved = "Saved"
|
||||
savedBody = "Login agreement updated for {{locale}}"
|
||||
textDescription = "Saved to customFiles/disclaimer/{{locale}}.md and shown live on the next login. Leave blank to remove this language."
|
||||
title = "Login Agreement"
|
||||
|
||||
[admin.settings.legal.loginAgreement.anonymous]
|
||||
label = "Show in anonymous (no-login) mode"
|
||||
|
||||
[admin.settings.legal.loginAgreement.enabled]
|
||||
label = "Enable login agreement"
|
||||
|
||||
[admin.settings.legal.privacyPolicy]
|
||||
description = "URL or filename to privacy policy"
|
||||
label = "Privacy Policy"
|
||||
|
||||
@@ -55,6 +55,10 @@ info = "Información"
|
||||
invalidUndoData = "No se puede deshacer: datos de la operación no válidos"
|
||||
keepWorking = "Seguir trabajando"
|
||||
loading = "Cargando..."
|
||||
loginAgreementAccept = "Aceptar"
|
||||
loginAgreementDecline = "Rechazar"
|
||||
loginAgreementProvider = "Este aviso lo proporciona su administrador, no Stirling PDF Inc."
|
||||
loginAgreementTitle = "Acuerdo de acceso"
|
||||
logOut = "Cerrar sesión"
|
||||
marginTooltip = "Distancia entre el número de página y el borde de la página."
|
||||
moreOptions = "Más Opciones"
|
||||
|
||||
@@ -55,6 +55,10 @@ info = "Informations"
|
||||
invalidUndoData = "Impossible d’annuler : données d’opération invalides"
|
||||
keepWorking = "Continuer à travailler"
|
||||
loading = "Chargement..."
|
||||
loginAgreementAccept = "Accepter"
|
||||
loginAgreementDecline = "Refuser"
|
||||
loginAgreementProvider = "Cet avis est fourni par votre administrateur, et non par Stirling PDF Inc."
|
||||
loginAgreementTitle = "Accord de connexion"
|
||||
logOut = "Se déconnecter"
|
||||
marginTooltip = "Distance entre le numéro de page et le bord de la page."
|
||||
moreOptions = "Plus d’options"
|
||||
|
||||
@@ -10,6 +10,8 @@ struct ProvisioningConfig<'a> {
|
||||
server_url: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
lock_connection_mode: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
login_agreement_enabled: Option<bool>,
|
||||
/// Optional headless-install update policy.
|
||||
/// One of `"prompt"` (default), `"auto"`, or `"disabled"`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -44,6 +46,7 @@ fn main() -> Result<(), String> {
|
||||
let mut output: Option<PathBuf> = None;
|
||||
let mut url: Option<String> = None;
|
||||
let mut lock_value: Option<String> = None;
|
||||
let mut login_agreement_value: Option<String> = None;
|
||||
let mut update_mode_arg: Option<String> = None;
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
@@ -67,6 +70,12 @@ fn main() -> Result<(), String> {
|
||||
.ok_or_else(|| "--lock requires a value".to_string())?;
|
||||
lock_value = Some(value);
|
||||
}
|
||||
"--login-agreement" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--login-agreement requires a value".to_string())?;
|
||||
login_agreement_value = Some(value);
|
||||
}
|
||||
"--update-mode" => {
|
||||
let value = args
|
||||
.next()
|
||||
@@ -84,6 +93,15 @@ fn main() -> Result<(), String> {
|
||||
let url = url
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
// Treat an empty/whitespace value as "not supplied" (None), matching url and
|
||||
// update-mode. The MSI always passes --login-agreement "[STIRLING_LOGIN_AGREEMENT]",
|
||||
// which expands to "" when the property is unset; that must NOT write
|
||||
// loginAgreementEnabled:false and clobber a previously-provisioned true.
|
||||
let login_agreement = login_agreement_value
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(parse_bool);
|
||||
|
||||
let update_mode = update_mode_arg
|
||||
.as_deref()
|
||||
@@ -91,9 +109,10 @@ fn main() -> Result<(), String> {
|
||||
.transpose()?
|
||||
.flatten();
|
||||
|
||||
// Nothing to write — avoid clobbering an existing provisioning file when
|
||||
// the MSI is invoked without any of STIRLING_SERVER_URL / STIRLING_UPDATE_MODE.
|
||||
if url.is_none() && update_mode.is_none() {
|
||||
// Nothing to write — avoid clobbering an existing provisioning file when the
|
||||
// MSI is invoked without any provisioning directives
|
||||
// (STIRLING_SERVER_URL / STIRLING_LOGIN_AGREEMENT / STIRLING_UPDATE_MODE).
|
||||
if url.is_none() && login_agreement.is_none() && update_mode.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -111,6 +130,7 @@ fn main() -> Result<(), String> {
|
||||
let config = ProvisioningConfig {
|
||||
server_url: url.as_deref(),
|
||||
lock_connection_mode: lock,
|
||||
login_agreement_enabled: login_agreement,
|
||||
update_mode,
|
||||
};
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
// Define all Java options with Tauri-specific paths
|
||||
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
|
||||
|
||||
let java_options = vec![
|
||||
let mut java_options = vec![
|
||||
"-Xmx2g",
|
||||
"-DBROWSER_OPEN=false",
|
||||
"-DSTIRLING_PDF_TAURI_MODE=true",
|
||||
@@ -212,10 +212,16 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
"-Dserver.port=0", // Let OS assign an available port
|
||||
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
|
||||
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
|
||||
"-jar",
|
||||
jar_path.to_str().unwrap(),
|
||||
];
|
||||
|
||||
// Enable the login agreement on local desktop installs when it has been provisioned.
|
||||
if crate::commands::connection::login_agreement_enabled(app) {
|
||||
java_options.push("-Dlegal.loginAgreement.enabled=true");
|
||||
}
|
||||
|
||||
java_options.push("-jar");
|
||||
java_options.push(jar_path.to_str().unwrap());
|
||||
|
||||
// Log the equivalent command for external testing
|
||||
let java_command = format!(
|
||||
"TAURI_PARENT_PID={} \"{}\" {}",
|
||||
|
||||
@@ -15,6 +15,7 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed";
|
||||
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
||||
const SERVER_CONFIG_KEY: &str = "server_config";
|
||||
const LOCK_CONNECTION_KEY: &str = "lock_connection_mode";
|
||||
const LOGIN_AGREEMENT_KEY: &str = "login_agreement_enabled";
|
||||
pub(crate) const UPDATE_MODE_KEY: &str = "update_mode";
|
||||
/// When `true` the update mode was written by a provisioning file and cannot
|
||||
/// be changed from the UI. Only another provisioning file (from MDM) can
|
||||
@@ -182,6 +183,7 @@ pub async fn set_connection_mode(
|
||||
struct ProvisioningConfig {
|
||||
server_url: Option<String>,
|
||||
lock_connection_mode: Option<bool>,
|
||||
login_agreement_enabled: Option<bool>,
|
||||
/// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`).
|
||||
/// When omitted the existing stored mode is left unchanged.
|
||||
update_mode: Option<UpdateMode>,
|
||||
@@ -236,14 +238,35 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
|
||||
let parsed: ProvisioningConfig = serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("Failed to parse provisioning file: {}", e))?;
|
||||
|
||||
// Login agreement can be provisioned independently of a server URL so it also applies to
|
||||
// local, no-login desktop installs. Persist it before the server-URL handling below, which
|
||||
// may early-return when no URL is present.
|
||||
if let Some(login_agreement_enabled) = parsed.login_agreement_enabled {
|
||||
if let Ok(store) = app_handle.store(STORE_FILE) {
|
||||
store.set(LOGIN_AGREEMENT_KEY, serde_json::json!(login_agreement_enabled));
|
||||
let _ = store.save();
|
||||
}
|
||||
add_log(format!(
|
||||
"🧩 Provisioned login agreement enabled = {}",
|
||||
login_agreement_enabled
|
||||
));
|
||||
}
|
||||
|
||||
let server_url = parsed
|
||||
.server_url
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if server_url.is_none() && parsed.update_mode.is_none() {
|
||||
// Only short-circuit when there is nothing left to apply. login_agreement is handled above,
|
||||
// but it must still be in this guard so a login-agreement-only file falls through to the
|
||||
// deletion block at the end (otherwise the per-user file would linger and re-apply forever).
|
||||
if server_url.is_none()
|
||||
&& parsed.update_mode.is_none()
|
||||
&& parsed.login_agreement_enabled.is_none()
|
||||
{
|
||||
add_log(
|
||||
"⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(),
|
||||
"⚠️ Provisioning file has no actionable fields (serverUrl/updateMode/loginAgreement); skipping apply"
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -335,6 +358,17 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the login agreement was provisioned as enabled. Read by the backend launcher to pass
|
||||
/// the `-Dlegal.loginAgreement.enabled` flag to the bundled JVM in local desktop mode.
|
||||
pub fn login_agreement_enabled(app_handle: &AppHandle) -> bool {
|
||||
app_handle
|
||||
.store(STORE_FILE)
|
||||
.ok()
|
||||
.and_then(|store| store.get(LOGIN_AGREEMENT_KEY))
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
let store = app_handle
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<Fragment>
|
||||
<Property Id="STIRLING_SERVER_URL" Secure="yes" />
|
||||
<Property Id="STIRLING_LOCK_CONNECTION" Secure="yes" />
|
||||
<!-- STIRLING_LOGIN_AGREEMENT — "1"/"true" enables the login agreement popup on this install. -->
|
||||
<Property Id="STIRLING_LOGIN_AGREEMENT" Secure="yes" />
|
||||
<!--
|
||||
STIRLING_UPDATE_MODE — headless auto-update policy for MDM/Intune deploys.
|
||||
Accepts "prompt" (default, user-interactive), "auto" (silent download+install on
|
||||
@@ -71,24 +73,25 @@
|
||||
<CustomAction
|
||||
Id="SetWriteProvisioningFilePerUser"
|
||||
Property="WriteProvisioningFilePerUser"
|
||||
Value="--output "[AppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||
Value="--output "[AppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --login-agreement "[STIRLING_LOGIN_AGREEMENT]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||
/>
|
||||
|
||||
<CustomAction
|
||||
Id="SetWriteProvisioningFileAllUsers"
|
||||
Property="WriteProvisioningFileAllUsers"
|
||||
Value="--output "[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||
Value="--output "[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json" --url "[STIRLING_SERVER_URL]" --lock "[STIRLING_LOCK_CONNECTION]" --login-agreement "[STIRLING_LOGIN_AGREEMENT]" --update-mode "[STIRLING_UPDATE_MODE]""
|
||||
/>
|
||||
|
||||
<!--
|
||||
Run the provisioner when EITHER STIRLING_SERVER_URL or STIRLING_UPDATE_MODE is set.
|
||||
The provisioner is a no-op if both values end up empty, so passing neither is safe.
|
||||
Run the provisioner when ANY of STIRLING_SERVER_URL, STIRLING_LOGIN_AGREEMENT, or
|
||||
STIRLING_UPDATE_MODE is set. The provisioner is a no-op if all values end up empty,
|
||||
so passing none is safe.
|
||||
-->
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
|
||||
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
|
||||
</InstallExecuteSequence>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
|
||||
import LoginAgreementModal from "@app/components/shared/LoginAgreementModal";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -27,6 +28,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||
</div>
|
||||
<NavigationWarningModal />
|
||||
<LoginAgreementModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
const ACCEPTED_STORAGE_KEY = "loginAgreementAccepted";
|
||||
|
||||
interface DisclaimerResponse {
|
||||
enabled: boolean;
|
||||
showInAnonymousMode: boolean;
|
||||
content: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
function readJwt(): string | null {
|
||||
try {
|
||||
return localStorage.getItem("stirling_jwt");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// A value that changes on each fresh login so the agreement re-shows per login, but stays
|
||||
// stable across page refreshes within the same logged-in tab session.
|
||||
// Opaque, non-reversible digest so we never persist any token material. Only
|
||||
// needs to change when the input changes (to re-trigger the disclaimer).
|
||||
function digest(value: string): string {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
h = ((h << 5) + h + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function getLoginNonce(loginEnabled: boolean, userId?: string): string {
|
||||
if (!loginEnabled) return "anon";
|
||||
const jwt = readJwt();
|
||||
if (jwt) return `jwt:${digest(jwt)}`;
|
||||
if (userId) return `user:${userId}`;
|
||||
return "session";
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
// Strip react-markdown's `node` prop so it isn't spread onto the DOM element.
|
||||
a({ node, ...props }) {
|
||||
return <a {...props} target="_blank" rel="noopener noreferrer" />;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Blocking login agreement / disclaimer shown once per login (and once per app session in
|
||||
* anonymous mode). Text is fetched live for the user's current language; admins manage it via
|
||||
* customFiles/disclaimer/<locale>.md.
|
||||
*/
|
||||
export default function LoginAgreementModal() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { user, signOut } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const nonceRef = useRef("anon");
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Never gate the login/auth screens themselves. pathname is a dep (not window.location) so
|
||||
// the gate re-runs when the SPA navigates from /login to / after an interactive login -
|
||||
// AppLayout stays mounted across that route change, so nothing else would re-trigger it.
|
||||
if (pathname.startsWith("/login")) return;
|
||||
|
||||
const loginEnabled = config.enableLogin !== false;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await apiClient.get<DisclaimerResponse>(
|
||||
"/api/v1/config/login-disclaimer",
|
||||
{
|
||||
params: { lang: i18n.language },
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
const data = resp.data;
|
||||
if (cancelled || !data?.enabled) return;
|
||||
if (!loginEnabled && !data.showInAnonymousMode) return;
|
||||
if (!data.content || !data.content.trim()) return;
|
||||
|
||||
const nonce = getLoginNonce(loginEnabled, user?.id);
|
||||
nonceRef.current = nonce;
|
||||
|
||||
let accepted: string | null = null;
|
||||
try {
|
||||
accepted = sessionStorage.getItem(ACCEPTED_STORAGE_KEY);
|
||||
} catch {
|
||||
accepted = null;
|
||||
}
|
||||
if (accepted === nonce) return;
|
||||
|
||||
setContent(data.content);
|
||||
setOpened(true);
|
||||
} catch {
|
||||
// On fetch error (unreachable/unauthorized) fail OPEN: don't block app usage on a
|
||||
// disclaimer we couldn't load.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [config, i18n.language, user?.id, pathname]);
|
||||
|
||||
const handleAccept = () => {
|
||||
try {
|
||||
sessionStorage.setItem(ACCEPTED_STORAGE_KEY, nonceRef.current);
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
if (loginEnabled) {
|
||||
setOpened(false);
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.location.assign("/login");
|
||||
} else {
|
||||
// Anonymous / desktop: best-effort close the window; if that is a no-op (web),
|
||||
// reload so the agreement re-blocks until accepted.
|
||||
window.close();
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
if (!opened) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}}
|
||||
title={t("loginAgreementTitle", "Login Agreement")}
|
||||
centered
|
||||
size="lg"
|
||||
radius="md"
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_SIGN_IN_MODAL}
|
||||
>
|
||||
<Stack>
|
||||
<ScrollArea.Autosize mah="50vh" type="auto">
|
||||
<Box px="xs">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</Box>
|
||||
</ScrollArea.Autosize>
|
||||
<Divider />
|
||||
<Group justify="space-between" gap="sm" align="center" wrap="wrap">
|
||||
<Text size="xs" c="dimmed" style={{ flex: 1, minWidth: 0 }}>
|
||||
{t(
|
||||
"loginAgreementProvider",
|
||||
"This notice is provided by your administrator, not Stirling PDF Inc.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button variant="default" onClick={handleDecline}>
|
||||
{t("loginAgreementDecline", "Decline")}
|
||||
</Button>
|
||||
<Button onClick={handleAccept}>
|
||||
{t("loginAgreementAccept", "Accept")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,12 @@ export async function mockAppApis(
|
||||
route.fulfill({ json: true }),
|
||||
);
|
||||
|
||||
// Login agreement / disclaimer — disabled by default so the blocking modal
|
||||
// never shows; specs exercising it register a narrower route afterwards.
|
||||
await page.route("**/api/v1/config/login-disclaimer*", (route: Route) =>
|
||||
route.fulfill({ json: { enabled: false } }),
|
||||
);
|
||||
|
||||
// Footer / branding — non-critical but proxied, so stub to avoid noise
|
||||
await page.route("**/api/v1/ui-data/footer-info", (route: Route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
mockAppApis,
|
||||
seedCookieConsent,
|
||||
skipOnboarding,
|
||||
} from "@app/tests/helpers/api-stubs";
|
||||
|
||||
/**
|
||||
* The LoginAgreementModal (AppLayout) shows a blocking Accept/Decline disclaimer after login
|
||||
* (and on launch in anonymous mode). Text comes from GET /api/v1/config/login-disclaimer for the
|
||||
* current language, rendered as markdown; acceptance is remembered per login for the tab session.
|
||||
*/
|
||||
|
||||
const MARKDOWN = "## Test Disclaimer\n\nThis is **mandatory** reading.";
|
||||
|
||||
interface DisclaimerStub {
|
||||
enabled?: boolean;
|
||||
showInAnonymousMode?: boolean;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
async function stubDisclaimer(page: Page, opts: DisclaimerStub = {}) {
|
||||
const {
|
||||
enabled = true,
|
||||
showInAnonymousMode = true,
|
||||
content = MARKDOWN,
|
||||
} = opts;
|
||||
await page.route("**/api/v1/config/login-disclaimer*", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: { enabled, showInAnonymousMode, content, format: "markdown" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function setUpLoggedIn(page: Page, disclaimer: DisclaimerStub = {}) {
|
||||
await seedCookieConsent(page);
|
||||
await skipOnboarding(page);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"stirling_jwt",
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
|
||||
);
|
||||
});
|
||||
await mockAppApis(page, {
|
||||
enableLogin: true,
|
||||
user: { id: 1, username: "admin", email: "admin", roles: ["ROLE_ADMIN"] },
|
||||
});
|
||||
await stubDisclaimer(page, disclaimer);
|
||||
}
|
||||
|
||||
test.describe("Login agreement modal", () => {
|
||||
test("shows a blocking disclaimer with rendered markdown after login", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setUpLoggedIn(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(
|
||||
page.getByText("Login Agreement", { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
// Markdown is rendered (heading + bold), not shown as raw text.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("mandatory")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Accept" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Decline" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Escape does not dismiss the modal (blocking)", async ({ page }) => {
|
||||
await setUpLoggedIn(page);
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Accept dismisses and it does not reappear on reload (once per login)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setUpLoggedIn(page);
|
||||
await page.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("does not show when the feature is disabled", async ({ page }) => {
|
||||
await setUpLoggedIn(page, { enabled: false, content: "" });
|
||||
await page.goto("/");
|
||||
// App is usable; modal never appears.
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("shows in anonymous (no-login) mode when allowed", async ({ page }) => {
|
||||
await seedCookieConsent(page);
|
||||
await skipOnboarding(page);
|
||||
await mockAppApis(page, { enableLogin: false });
|
||||
await stubDisclaimer(page, { showInAnonymousMode: true });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("does not show in anonymous mode when suppressed", async ({ page }) => {
|
||||
await seedCookieConsent(page);
|
||||
await skipOnboarding(page);
|
||||
await mockAppApis(page, { enableLogin: false });
|
||||
await stubDisclaimer(page, { showInAnonymousMode: false });
|
||||
await page.goto("/");
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test Disclaimer" }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
+91
@@ -8,6 +8,8 @@ import {
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
Switch,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { alert } from "@app/components/toast";
|
||||
@@ -19,6 +21,7 @@ import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import LoginAgreementEditor from "@app/components/shared/config/configSections/LoginAgreementEditor";
|
||||
|
||||
interface LegalSettingsData {
|
||||
termsAndConditions?: string;
|
||||
@@ -26,6 +29,11 @@ interface LegalSettingsData {
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
loginAgreement?: {
|
||||
enabled?: boolean;
|
||||
showInAnonymousMode?: boolean;
|
||||
fallbackText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminLegalSection() {
|
||||
@@ -48,6 +56,23 @@ export default function AdminLegalSection() {
|
||||
isFieldPending,
|
||||
} = useAdminSettings<LegalSettingsData>({
|
||||
sectionName: "legal",
|
||||
// The flat legal URL fields save through the section endpoint as before; the nested
|
||||
// loginAgreement object is flattened to dotted keys sent via the global settings endpoint
|
||||
// (updateSettingsTransactional), which merges into the existing node. Saving a partial
|
||||
// nested object through the section endpoint would replace the whole loginAgreement node
|
||||
// and drop the sibling keys the UI didn't touch (e.g. fallbackText). fallbackText is not
|
||||
// edited here, so it is deliberately omitted and left untouched.
|
||||
saveTransformer: (current: LegalSettingsData) => {
|
||||
const { loginAgreement, ...flat } = current;
|
||||
const deltaSettings: Record<string, unknown> = {};
|
||||
if (loginAgreement) {
|
||||
deltaSettings["legal.loginAgreement.enabled"] =
|
||||
loginAgreement.enabled ?? false;
|
||||
deltaSettings["legal.loginAgreement.showInAnonymousMode"] =
|
||||
loginAgreement.showInAnonymousMode ?? true;
|
||||
}
|
||||
return { sectionData: flat, deltaSettings };
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -264,6 +289,72 @@ export default function AdminLegalSection() {
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.title",
|
||||
"Login Agreement",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.description",
|
||||
"Show a disclaimer users must accept after logging in. The text follows each user's language.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
label={t(
|
||||
"admin.settings.legal.loginAgreement.enabled.label",
|
||||
"Enable login agreement",
|
||||
)}
|
||||
checked={settings.loginAgreement?.enabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
loginAgreement: {
|
||||
...settings.loginAgreement,
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t(
|
||||
"admin.settings.legal.loginAgreement.anonymous.label",
|
||||
"Show in anonymous (no-login) mode",
|
||||
)}
|
||||
checked={settings.loginAgreement?.showInAnonymousMode ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
loginAgreement: {
|
||||
...settings.loginAgreement,
|
||||
showInAnonymousMode: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.restartNote",
|
||||
"Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<LoginAgreementEditor disabled={!loginEnabled} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { supportedLanguages } from "@app/i18n";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
const languageOptions = Object.entries(supportedLanguages).map(
|
||||
([value, label]) => ({ value, label: `${label} (${value})` }),
|
||||
);
|
||||
|
||||
interface LoginAgreementEditorProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-language editor for the login agreement markdown. Reads/writes
|
||||
* customFiles/disclaimer/<locale>.md directly via the admin endpoint, so saved text takes effect
|
||||
* on the next login without a restart (unlike the enable flags, which go through settings).
|
||||
*/
|
||||
export default function LoginAgreementEditor({
|
||||
disabled,
|
||||
}: LoginAgreementEditorProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const initialLocale = Object.prototype.hasOwnProperty.call(
|
||||
supportedLanguages,
|
||||
i18n.language,
|
||||
)
|
||||
? i18n.language
|
||||
: "en-US";
|
||||
|
||||
const [locale, setLocale] = useState<string>(initialLocale);
|
||||
const [content, setContent] = useState("");
|
||||
const [loadedContent, setLoadedContent] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await apiClient.get(
|
||||
`/api/v1/admin/login-agreement/${encodeURIComponent(locale)}`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const loaded = resp.data?.content ?? "";
|
||||
setContent(loaded);
|
||||
setLoadedContent(loaded);
|
||||
setLoadFailed(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
// Surface the failure instead of silently showing a blank editor (which is
|
||||
// indistinguishable from "no file yet"), and keep Save disabled so a stale buffer
|
||||
// can't overwrite a file that exists but failed to load.
|
||||
setContent("");
|
||||
setLoadedContent("");
|
||||
setLoadFailed(true);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t(
|
||||
"admin.settings.legal.loginAgreement.loadError",
|
||||
"Failed to load the agreement for {{locale}}. Switch language and back to retry.",
|
||||
{ locale },
|
||||
),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale, t]);
|
||||
|
||||
const dirty = content !== loadedContent;
|
||||
|
||||
const handleLocaleChange = (value: string | null) => {
|
||||
if (!value || value === locale) return;
|
||||
// Don't silently discard unsaved markdown when switching languages.
|
||||
if (
|
||||
dirty &&
|
||||
!window.confirm(
|
||||
t(
|
||||
"admin.settings.legal.loginAgreement.discardConfirm",
|
||||
"You have unsaved changes that will be lost. Switch language anyway?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLocale(value);
|
||||
};
|
||||
|
||||
// Jump to the General settings section where the default locale (the fallback used when a
|
||||
// language has no file) is configured. Guard unsaved edits like the language switch.
|
||||
const goToDefaultLocaleSetting = () => {
|
||||
if (
|
||||
dirty &&
|
||||
!window.confirm(
|
||||
t(
|
||||
"admin.settings.legal.loginAgreement.discardConfirm",
|
||||
"You have unsaved changes that will be lost. Switch language anyway?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
navigate("/settings/adminGeneral");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(
|
||||
`/api/v1/admin/login-agreement/${encodeURIComponent(locale)}`,
|
||||
{ content },
|
||||
);
|
||||
setLoadedContent(content);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("admin.settings.legal.loginAgreement.saved", "Saved"),
|
||||
body: t(
|
||||
"admin.settings.legal.loginAgreement.savedBody",
|
||||
"Login agreement updated for {{locale}}",
|
||||
{ locale },
|
||||
),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-end" justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Select
|
||||
label={
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<span>
|
||||
{t("admin.settings.legal.loginAgreement.language", "Language")}
|
||||
</span>
|
||||
<Tooltip
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
label={t(
|
||||
"admin.settings.legal.loginAgreement.languageHelp",
|
||||
"Each language has its own file. If a user's language has no file, the agreement falls back to the default locale's file, then to the fallback text.",
|
||||
)}
|
||||
>
|
||||
<InfoOutlinedIcon
|
||||
style={{
|
||||
fontSize: 15,
|
||||
cursor: "help",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
data={languageOptions}
|
||||
value={locale}
|
||||
onChange={handleLocaleChange}
|
||||
searchable
|
||||
disabled={disabled || saving}
|
||||
maxDropdownHeight={280}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
style={{ flex: 1, maxWidth: 340 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={disabled || loading || loadFailed || !dirty}
|
||||
>
|
||||
{t("admin.settings.legal.loginAgreement.save", "Save text")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Textarea
|
||||
label={t("admin.settings.legal.loginAgreement.editLabel", "Markdown")}
|
||||
placeholder={"## Heading\n\nYour disclaimer text..."}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={12}
|
||||
maxRows={28}
|
||||
disabled={disabled || loading}
|
||||
styles={{
|
||||
input: { fontFamily: "var(--mantine-font-family-monospace)" },
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
{t("admin.settings.legal.loginAgreement.previewLabel", "Preview")}
|
||||
</Text>
|
||||
<Paper withBorder p="md" radius="sm" mih={200}>
|
||||
{content.trim() ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.emptyPreview",
|
||||
"Nothing to preview yet.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.textDescription",
|
||||
"Saved to customFiles/disclaimer/{{locale}}.md and shown live on the next login. Leave blank to remove this language.",
|
||||
{ locale },
|
||||
)}{" "}
|
||||
<Anchor
|
||||
size="xs"
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={goToDefaultLocaleSetting}
|
||||
>
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.defaultLocaleLink",
|
||||
"Configure the default locale",
|
||||
)}
|
||||
</Anchor>{" "}
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.defaultLocaleHint",
|
||||
"— used as the fallback when a language has no file.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{loading && <Loader size="xs" />}
|
||||
{loadFailed && !loading && (
|
||||
<Text size="xs" c="red">
|
||||
{t(
|
||||
"admin.settings.legal.loginAgreement.loadError",
|
||||
"Failed to load the agreement for {{locale}}. Switch language and back to retry.",
|
||||
{ locale },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user