Add login agreement disclaimer feature (#6766)

This commit is contained in:
Anthony Stirling
2026-06-24 22:07:19 +01:00
committed by GitHub
parent d06d3cabaf
commit bc6f1a1ff5
23 changed files with 1523 additions and 16 deletions
@@ -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();
}
}
@@ -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")));
}
}
@@ -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
@@ -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());
}
}
@@ -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/&lt;locale&gt;.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) {}
}
@@ -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 dannuler : données dopé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 doptions"
@@ -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 &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
Value="--output &quot;[AppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --login-agreement &quot;[STIRLING_LOGIN_AGREEMENT]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<CustomAction
Id="SetWriteProvisioningFileAllUsers"
Property="WriteProvisioningFileAllUsers"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
Value="--output &quot;[CommonAppDataFolder]Stirling-PDF\stirling-provisioning.json&quot; --url &quot;[STIRLING_SERVER_URL]&quot; --lock &quot;[STIRLING_LOCK_CONNECTION]&quot; --login-agreement &quot;[STIRLING_LOGIN_AGREEMENT]&quot; --update-mode &quot;[STIRLING_UPDATE_MODE]&quot;"
/>
<!--
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 &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="SetWriteProvisioningFilePerUser" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="WriteProvisioningFilePerUser" After="SetWriteProvisioningFilePerUser">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (NOT ALLUSERS OR ALLUSERS=0)</Custom>
<Custom Action="SetWriteProvisioningFileAllUsers" After="InstallFiles">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) AND (ALLUSERS=1 OR ALLUSERS=2)</Custom>
<Custom Action="WriteProvisioningFileAllUsers" After="SetWriteProvisioningFileAllUsers">(STIRLING_SERVER_URL &lt;&gt; &quot;&quot; OR STIRLING_LOGIN_AGREEMENT &lt;&gt; &quot;&quot; OR STIRLING_UPDATE_MODE &lt;&gt; &quot;&quot;) 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();
});
});
@@ -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
@@ -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>
);
}