From bc6f1a1ff5ff92f7fb6020ff6c421f9f331a41d6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:07:19 +0100 Subject: [PATCH] Add login agreement disclaimer feature (#6766) --- .../common/model/ApplicationProperties.java | 8 + .../common/service/LoginAgreementService.java | 204 +++++++++++++ .../service/LoginAgreementServiceTest.java | 201 +++++++++++++ .../api/misc/LoginDisclaimerController.java | 49 +++ .../src/main/resources/settings.yml.template | 4 + .../misc/LoginDisclaimerControllerTest.java | 63 ++++ .../api/AdminLoginAgreementController.java | 74 +++++ .../AdminLoginAgreementControllerTest.java | 76 +++++ .../public/locales/de-DE/translation.toml | 4 + .../public/locales/en-GB/translation.toml | 4 + .../public/locales/en-US/translation.toml | 28 ++ .../public/locales/es-ES/translation.toml | 4 + .../public/locales/fr-FR/translation.toml | 4 + .../editor/src-tauri/provisioner/src/main.rs | 26 +- .../editor/src-tauri/src/commands/backend.rs | 12 +- .../src-tauri/src/commands/connection.rs | 38 ++- .../src-tauri/windows/wix/provisioning.wxs | 19 +- .../editor/src/core/components/AppLayout.tsx | 2 + .../components/shared/LoginAgreementModal.tsx | 202 +++++++++++++ .../src/core/tests/helpers/api-stubs.ts | 6 + .../stubbed/login-agreement-modal.spec.ts | 139 +++++++++ .../configSections/AdminLegalSection.tsx | 91 ++++++ .../configSections/LoginAgreementEditor.tsx | 281 ++++++++++++++++++ 23 files changed, 1523 insertions(+), 16 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java create mode 100644 app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java create mode 100644 frontend/editor/src/core/components/shared/LoginAgreementModal.tsx create mode 100644 frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e80d6b2360..8f7e878aef 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java b/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java new file mode 100644 index 0000000000..ddde3fa1a8 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java @@ -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/.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 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 listLocalesWithContent() { + Set result = new TreeSet<>(); + Path dir = disclaimerDir(); + if (!Files.isDirectory(dir)) { + return result; + } + try (Stream 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 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(); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java b/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java new file mode 100644 index 0000000000..237242b1f3 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java @@ -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 /disclaimer/.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 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"))); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java new file mode 100644 index 0000000000..241caff12d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java @@ -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) {} +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1e9e103742..fe2b8635aa 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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/.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/.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 diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java new file mode 100644 index 0000000000..31b64cb2c7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java @@ -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()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java new file mode 100644 index 0000000000..320ea7fcd0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java @@ -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 listLocales() { + return loginAgreementService.listLocalesWithContent(); + } + + @GetMapping("/{locale}") + @Operation(summary = "Read the login agreement markdown for a locale") + public ResponseEntity> 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 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) {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java new file mode 100644 index 0000000000..13898b1562 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java @@ -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 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 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 resp = controller.write("fr-FR", new DisclaimerContentRequest("x")); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); + } +} diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 55a5022189..97c9192824 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 7a97f4e4a9..f3c7a77ae5 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 84f9c26de5..14768a3208 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index ecac7c9ef1..8856536f82 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -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" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index d4def7ab17..49baa951a4 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -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" diff --git a/frontend/editor/src-tauri/provisioner/src/main.rs b/frontend/editor/src-tauri/provisioner/src/main.rs index 1a316945c5..b946c31289 100644 --- a/frontend/editor/src-tauri/provisioner/src/main.rs +++ b/frontend/editor/src-tauri/provisioner/src/main.rs @@ -10,6 +10,8 @@ struct ProvisioningConfig<'a> { server_url: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] lock_connection_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + login_agreement_enabled: Option, /// 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 = None; let mut url: Option = None; let mut lock_value: Option = None; + let mut login_agreement_value: Option = None; let mut update_mode_arg: Option = 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, }; diff --git a/frontend/editor/src-tauri/src/commands/backend.rs b/frontend/editor/src-tauri/src/commands/backend.rs index 00dc1bb486..50d9a24a26 100644 --- a/frontend/editor/src-tauri/src/commands/backend.rs +++ b/frontend/editor/src-tauri/src/commands/backend.rs @@ -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={} \"{}\" {}", diff --git a/frontend/editor/src-tauri/src/commands/connection.rs b/frontend/editor/src-tauri/src/commands/connection.rs index 068d82b732..a12040fb55 100644 --- a/frontend/editor/src-tauri/src/commands/connection.rs +++ b/frontend/editor/src-tauri/src/commands/connection.rs @@ -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, lock_connection_mode: Option, + login_agreement_enabled: Option, /// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`). /// When omitted the existing stored mode is left unchanged. update_mode: Option, @@ -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 { let store = app_handle diff --git a/frontend/editor/src-tauri/windows/wix/provisioning.wxs b/frontend/editor/src-tauri/windows/wix/provisioning.wxs index f523baa00a..6bf03d870f 100644 --- a/frontend/editor/src-tauri/windows/wix/provisioning.wxs +++ b/frontend/editor/src-tauri/windows/wix/provisioning.wxs @@ -3,6 +3,8 @@ + + - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) diff --git a/frontend/editor/src/core/components/AppLayout.tsx b/frontend/editor/src/core/components/AppLayout.tsx index 1f0e969777..38ab1f1907 100644 --- a/frontend/editor/src/core/components/AppLayout.tsx +++ b/frontend/editor/src/core/components/AppLayout.tsx @@ -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) {
{children}
+ ); } diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx new file mode 100644 index 0000000000..8d419e7cc0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx @@ -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 ; + }, +}; + +/** + * 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/.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( + "/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 ( + {}} + title={t("loginAgreementTitle", "Login Agreement")} + centered + size="lg" + radius="md" + closeOnClickOutside={false} + closeOnEscape={false} + withCloseButton={false} + zIndex={Z_INDEX_SIGN_IN_MODAL} + > + + + + + {content} + + + + + + + {t( + "loginAgreementProvider", + "This notice is provided by your administrator, not Stirling PDF Inc.", + )} + + + + + + + + + ); +} diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index ef1f4d090b..00d4d98b81 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -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: {} }), diff --git a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts new file mode 100644 index 0000000000..2dccef4959 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts @@ -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(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx index b86d554014..4d22a8d304 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx @@ -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({ 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 = {}; + 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() { + + + +
+ + {t( + "admin.settings.legal.loginAgreement.title", + "Login Agreement", + )} + + + {t( + "admin.settings.legal.loginAgreement.description", + "Show a disclaimer users must accept after logging in. The text follows each user's language.", + )} + +
+ + + setSettings({ + ...settings, + loginAgreement: { + ...settings.loginAgreement, + enabled: e.currentTarget.checked, + }, + }) + } + disabled={!loginEnabled} + /> + + + setSettings({ + ...settings, + loginAgreement: { + ...settings.loginAgreement, + showInAnonymousMode: e.currentTarget.checked, + }, + }) + } + disabled={!loginEnabled} + /> + + + {t( + "admin.settings.legal.loginAgreement.restartNote", + "Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately.", + )} + + + + + +
+
({ value, label: `${label} (${value})` }), +); + +interface LoginAgreementEditorProps { + disabled?: boolean; +} + +/** + * Per-language editor for the login agreement markdown. Reads/writes + * customFiles/disclaimer/.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(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 ( + + +