Compare commits

..
Author SHA1 Message Date
Ludy87 2c1021c663 fix(frontend): make development label detection cross-platform 2026-07-12 01:42:05 +02:00
47 changed files with 194 additions and 1251 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
]
}
+2 -1
View File
@@ -85,7 +85,8 @@ tasks:
# full path, hostname, or user. Consumed at dev-serve time by vite.config
# and dropped from production builds.
STIRLING_DEV_LABEL:
sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
@@ -144,10 +144,8 @@ public class TempFileCleanupService {
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
if (Files.exists(directory)) {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
@@ -277,21 +275,6 @@ public class TempFileCleanupService {
return totalDeletedCount.get();
}
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
if (maxAgeMillis <= 0) {
return true;
}
try {
long currentTime = System.currentTimeMillis();
long lastModified = Files.getLastModifiedTime(directory).toMillis();
return (currentTime - lastModified) > maxAgeMillis;
} catch (IOException e) {
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
return false;
}
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
@@ -155,7 +155,6 @@ public class TempFileManager {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
@@ -85,18 +85,6 @@ public class TempFileRegistry {
return directory;
}
/**
* Unregister a temporary directory from the registry.
*
* @param directory The directory to unregister
*/
public void unregisterDirectory(Path directory) {
if (directory != null) {
tempDirectories.remove(directory);
log.debug("Unregistered temp directory: {}", directory.toString());
}
}
/**
* Register a third-party temporary file that requires special handling.
*
@@ -176,13 +176,11 @@ class TempFileCleanupServiceMoreTest {
class ScheduledCleanup {
@Test
@DisplayName("deletes stale registered temp directories and reports counts")
@DisplayName("deletes registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Files.setLastModifiedTime(
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
@@ -195,22 +193,6 @@ class TempFileCleanupServiceMoreTest {
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("keeps a fresh registered temp directory")
void keepsFreshRegisteredDirectory() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
assertThat(Files.exists(regDir)).isTrue();
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
@@ -72,8 +72,6 @@ spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
# Batch associations into IN() loads so list endpoints don't N+1 as tables grow.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# using database initialization scripts or tools.
-1
View File
@@ -89,7 +89,6 @@ dependencies {
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:postgresql:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.access.service;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -43,47 +42,6 @@ public class ResourceAccessService {
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
}
/** Portal access for a roster (admin, grant, or default policy). */
public Set<Long> usersWithPortalAccess(Collection<User> users, Set<Long> teamLeaderUserIds) {
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
for (ResourceGrant g :
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
if (permissionSatisfies(g.getPermission(), AccessPermission.USE)) {
grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()));
}
}
Set<Long> leaderIds = teamLeaderUserIds == null ? Set.of() : teamLeaderUserIds;
Set<Long> allowed = new HashSet<>();
for (User user : users) {
if (user != null
&& user.getId() != null
&& hasPortalAccess(user, grantedPrincipals, leaderIds)) {
allowed.add(user.getId());
}
}
return allowed;
}
private boolean hasPortalAccess(
User user, Set<PrincipalRef> grantedPrincipals, Set<Long> leaderIds) {
if (isAdmin(user)) {
return true;
}
for (PrincipalRef principal : principalResolver.principalsOf(user)) {
if (grantedPrincipals.contains(principal)) {
return true;
}
}
if (portalDefaultPolicy == null) {
return false;
}
return switch (portalDefaultPolicy) {
case ORG_ALL -> principalResolver.allowsDeploymentWideAccess();
case ADMINS_AND_TEAM_LEADS -> leaderIds.contains(user.getId());
case EXPLICIT_ONLY -> false;
};
}
/** Whether the user may use a resource, falling back to its default policy. */
public boolean canUseResource(
ResourceType type,
@@ -3,6 +3,7 @@ package stirling.software.proprietary.controller.api;
import static stirling.software.common.util.ProviderUtils.validateProvider;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@@ -44,6 +45,7 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
@@ -167,8 +169,16 @@ public class ProprietaryUIDataController {
boolean isFirstTimeSetup = false;
boolean showDefaultCredentials = false;
// Count real users, excluding the internal API user.
long userCount = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
List<User> allUsers = userRepository.findAll();
List<User> realUsers =
allUsers.stream()
.filter(
user ->
!Role.INTERNAL_API_USER
.getRoleId()
.equals(user.getUsername()))
.toList();
long userCount = realUsers.size();
if (userCount == 0) {
isFirstTimeSetup = true;
@@ -255,67 +265,92 @@ public class ProprietaryUIDataController {
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Drop the internal API user and internal-team members; the roster never shows them.
boolean hasInternalApiUser = false;
List<User> visibleUsers = new ArrayList<>(allUsers.size());
for (User user : allUsers) {
if (user == null) {
continue;
}
if (isInternalApiUser(user)) {
hasInternalApiUser = true;
continue;
}
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
continue;
}
visibleUsers.add(user);
}
if (hasInternalApiUser) {
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
}
// All users' settings in one query (mfaSecret masked).
Map<Long, Map<String, String>> settingsByUserId =
loadSettingsByUserId(visibleUsers.stream().map(User::getId).toList());
// Active = any non-expired session within the inactivity window; expiry is left to
// SessionScheduled.
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
Instant activeCutoff = Instant.now().minusSeconds(maxInactiveInterval);
Map<String, Instant> lastRequestByPrincipal = new HashMap<>();
for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) {
if (row[0] != null) {
lastRequestByPrincipal.put((String) row[0], (Instant) row[1]);
}
}
Set<String> activePrincipals =
new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff));
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
Map<String, Map<String, String>> userSettings = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
for (User user : visibleUsers) {
String username = user.getUsername();
boolean hasActiveSession = activePrincipals.contains(username);
Instant lastRequest = lastRequestByPrincipal.get(username);
userSessions.put(username, hasActiveSession);
userLastRequest.put(
username, lastRequest != null ? Date.from(lastRequest) : new Date(0));
userSettings.put(username, maskSecrets(settingsByUserId.get(user.getId())));
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
String username = user.getUsername();
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
break;
}
}
// Check if user is part of the Internal team
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true;
}
if (shouldRemove) {
iterator.remove();
continue;
}
// Session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false;
Date lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(username);
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
Instant lastAccessedTime =
Optional.ofNullable(sessionEntity.getLastRequest())
.orElse(Instant.EPOCH);
Instant now = Instant.now();
Instant expirationTime =
lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
} else {
hasActiveSession = !sessionEntity.isExpired();
}
lastRequest = Date.from(lastAccessedTime);
} else {
lastRequest = new Date(0);
}
User userWithSettings =
userRepository.findByIdWithSettings(user.getId()).orElse(user);
// Mask mfaSecret if present in settings
Map<String, String> originalSettings = userWithSettings.getSettings();
Map<String, String> settingsCopy =
originalSettings != null
? new HashMap<>(originalSettings)
: new HashMap<>();
if (settingsCopy.containsKey("mfaSecret")) {
settingsCopy.put("mfaSecret", "********");
}
userSettings.put(username, settingsCopy);
userSessions.put(username, hasActiveSession);
userLastRequest.put(username, lastRequest);
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
}
}
// Sort users by active status and last request date
List<User> sortedUsers =
visibleUsers.stream()
allUsers.stream()
.sorted(
(u1, u2) -> {
boolean u1Active = userSessions.get(u1.getUsername());
@@ -345,13 +380,11 @@ public class ProprietaryUIDataController {
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
// Resolve portal access for the whole roster.
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
Set<Long> leaderUserIds = leaderUserIds();
Set<Long> portalAccessUserIds =
resourceAccessService.usersWithPortalAccess(sortedUsers, leaderUserIds);
List<AdminUserSummary> userSummaries =
sortedUsers.stream()
.map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds))
.map(user -> convertUserToSummary(user, leaderUserIds))
.toList();
AdminSettingsData data = new AdminSettingsData();
@@ -360,7 +393,7 @@ public class ProprietaryUIDataController {
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
data.setTotalUsers(visibleUsers.size());
data.setTotalUsers(allUsers.size());
data.setActiveUsers(activeUsers);
data.setDisabledUsers(disabledUsers);
data.setTeams(allTeams);
@@ -483,8 +516,7 @@ public class ProprietaryUIDataController {
}
List<User> teamUsers = userRepository.findAllByTeamId(id);
// Fetch authorities + team for the available-users list.
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
List<User> allUsers = userRepository.findAllWithTeam();
List<User> availableUsers =
allUsers.stream()
.filter(
@@ -543,48 +575,17 @@ public class ProprietaryUIDataController {
.collect(Collectors.toSet());
}
/** Whether the user holds the internal-API authority (never shown in the roster). */
private boolean isInternalApiUser(User user) {
for (Authority authority : user.getAuthorities()) {
if (Role.INTERNAL_API_USER.getRoleId().equals(authority.getAuthority())) {
return true;
}
}
return false;
}
/** Assemble per-user settings maps from the flat (id, key, value) rows of one bulk query. */
private Map<Long, Map<String, String>> loadSettingsByUserId(List<Long> userIds) {
Map<Long, Map<String, String>> byUser = new HashMap<>();
if (userIds.isEmpty()) {
return byUser;
}
for (Object[] row : userRepository.findSettingsByUserIds(userIds)) {
byUser.computeIfAbsent((Long) row[0], id -> new HashMap<>())
.put((String) row[1], (String) row[2]);
}
return byUser;
}
/** Copy a settings map with mfaSecret masked; null-safe. */
private Map<String, String> maskSecrets(Map<String, String> settings) {
Map<String, String> copy = settings != null ? new HashMap<>(settings) : new HashMap<>();
if (copy.containsKey("mfaSecret")) {
copy.put("mfaSecret", "********");
}
return copy;
}
/**
* Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in.
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
* apiKey.
*/
private AdminUserSummary convertUserToSummary(
User user, Set<Long> leaderUserIds, Set<Long> portalAccessUserIds) {
private AdminUserSummary convertUserToSummary(User user, Set<Long> leaderUserIds) {
AdminUserSummary summary = new AdminUserSummary();
summary.setId(user.getId());
summary.setTeamLead(leaderUserIds.contains(user.getId()));
// Portal access (same policy /me uses).
summary.setPortalAccess(portalAccessUserIds.contains(user.getId()));
// Authoritative portal access, same call /me uses, so the roster honors the configured
// policy instead of the frontend guessing from role/team-leadership.
summary.setPortalAccess(resourceAccessService.canAccessPortal(user));
summary.setUsername(user.getUsername());
summary.setEmail(user.getUsername()); // Use username as email for consistency
summary.setRoleName(user.getRoleName());
@@ -24,12 +24,7 @@ import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "team_memberships",
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})},
// Distinct names from the saas Flyway idx_team_memberships_* to avoid a ddl-auto collision.
indexes = {
@Index(name = "idx_tm_user_role", columnList = "user_id, role"), // leader-set lookups
@Index(name = "idx_tm_team_role", columnList = "team_id, role") // per-team member lists
})
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})})
@NoArgsConstructor
@Getter
@Setter
@@ -45,29 +45,4 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
+ "WHERE u.team.id = :teamId "
+ "GROUP BY u.username")
List<Object[]> findLatestSessionByTeamId(@Param("teamId") Long teamId);
/** Latest request instant per principal. */
@Query(
"SELECT s.principalName, MAX(s.lastRequest) FROM SessionEntity s GROUP BY s.principalName")
List<Object[]> findLatestRequestPerPrincipal();
/** Principals with a live (non-expired, within-window) session. */
@Query(
"SELECT DISTINCT s.principalName FROM SessionEntity s "
+ "WHERE s.expired = false AND s.lastRequest > :cutoff")
List<String> findActivePrincipalsSince(@Param("cutoff") Instant cutoff);
/** Flag timed-out sessions as expired. */
@Modifying
@Transactional
@Query(
"UPDATE SessionEntity s SET s.expired = true "
+ "WHERE s.expired = false AND s.lastRequest < :cutoff")
int expireOlderThan(@Param("cutoff") Instant cutoff);
/** Purge long-expired sessions to bound table growth. */
@Modifying
@Transactional
@Query("DELETE FROM SessionEntity s WHERE s.expired = true AND s.lastRequest < :cutoff")
int deleteExpiredOlderThan(@Param("cutoff") Instant cutoff);
}
@@ -1,13 +1,11 @@
package stirling.software.proprietary.security.database.repository;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -45,15 +43,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
List<User> findAllWithTeam();
/** All users with team + authorities fetched (DISTINCT dedupes the collection join). */
@EntityGraph(attributePaths = {"team", "authorities"})
@Query("SELECT DISTINCT u FROM User u")
List<User> findAllWithTeamAndAuthorities();
/** (userId, key, value) settings rows for the given users. */
@Query("SELECT u.id, KEY(s), VALUE(s) FROM User u JOIN u.settings s WHERE u.id IN :ids")
List<Object[]> findSettingsByUserIds(@Param("ids") Collection<Long> ids);
@Query(
"SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
List<User> findAllByTeamId(@Param("teamId") Long teamId);
@@ -11,7 +11,6 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@@ -20,10 +19,7 @@ import lombok.Getter;
import lombok.Setter;
@Entity
@Table(
name = "authorities",
// index the FK: authorities load by user_id
indexes = @Index(name = "idx_authorities_user_id", columnList = "user_id"))
@Table(name = "authorities")
@Getter
@Setter
public class Authority implements GrantedAuthority, Serializable {
@@ -5,23 +5,13 @@ import java.time.Instant;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
@Entity
@Data
@Table(
name = "sessions",
indexes = {
// per-principal session/activity lookups
@Index(
name = "idx_sessions_principal_last",
columnList = "principal_name, last_request"),
// scheduled expiry/purge scan
@Index(name = "idx_sessions_expired", columnList = "expired")
})
@Table(name = "sessions")
public class SessionEntity implements Serializable {
@Id private String sessionId;
@@ -28,10 +28,7 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@Entity
@Table(
name = "users",
// team_id backs Team.users joins, the admin roster fetch, and per-team user counts.
indexes = @Index(name = "idx_users_team_id", columnList = "team_id"))
@Table(name = "users")
@NoArgsConstructor
@Getter
@Setter
@@ -146,16 +146,6 @@ public class SessionPersistentRegistry implements SessionRegistry {
return sessionRepository.findAll();
}
// Flag every session idle past the timeout.
public int expireStaleSessions() {
return sessionRepository.expireOlderThan(Instant.now().minus(defaultMaxInactiveInterval));
}
// Purge sessions expired longer than the retention window.
public int purgeExpiredSessions(Duration retention) {
return sessionRepository.deleteExpiredOlderThan(Instant.now().minus(retention));
}
// Mark a session as expired
public void expireSession(String sessionId) {
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
@@ -1,8 +1,12 @@
package stirling.software.proprietary.security.session;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -11,15 +15,23 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SessionScheduled {
// Retention before an expired session is purged.
private static final Duration EXPIRED_SESSION_RETENTION = Duration.ofDays(30);
private final SessionPersistentRegistry sessionPersistentRegistry;
@Scheduled(cron = "0 0/5 * * * ?")
public void expireSessions() {
// Flag timed-out sessions, then purge long-dead ones.
sessionPersistentRegistry.expireStaleSessions();
sessionPersistentRegistry.purgeExpiredSessions(EXPIRED_SESSION_RETENTION);
Instant now = Instant.now();
for (Object principal : sessionPersistentRegistry.getAllPrincipals()) {
List<SessionInformation> sessionInformations =
sessionPersistentRegistry.getAllSessions(principal, false);
for (SessionInformation sessionInformation : sessionInformations) {
Date lastRequest = sessionInformation.getLastRequest();
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
Instant expirationTime =
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionInformation.getSessionId());
}
}
}
}
}
@@ -1,151 +0,0 @@
package stirling.software.proprietary.access.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.PrincipalRef;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
/** Bulk portal-access must match per-user canAccessPortal for every policy. */
@ExtendWith(MockitoExtension.class)
class ResourceAccessPortalBulkParityTest {
@Mock private ResourceGrantRepository grantRepository;
@Mock private TeamLeadLookup teamLeadLookup;
private ResourceAccessService service;
private User admin;
private User leader;
private User userGrantHolder;
private User teamGrantMember;
private User plainMember;
private List<User> everyone;
private Set<Long> leaderUserIds;
void setUp(DefaultAccessPolicy policy) {
service =
new ResourceAccessService(
grantRepository, teamLeadLookup, new DefaultPrincipalResolver());
ReflectionTestUtils.setField(service, "portalDefaultPolicy", policy);
admin = user(1L, null, Role.ADMIN.getRoleId());
leader = user(2L, 10L, Role.USER.getRoleId());
userGrantHolder = user(3L, null, Role.USER.getRoleId());
teamGrantMember = user(4L, 20L, Role.USER.getRoleId());
plainMember = user(5L, 10L, Role.USER.getRoleId());
everyone = List.of(admin, leader, userGrantHolder, teamGrantMember, plainMember);
// Grants: a USER grant to #3 and a TEAM grant to team 20 (which #4 belongs to).
lenient()
.when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
.thenReturn(
List.of(
grant(PrincipalType.USER, 3L, AccessPermission.USE),
grant(PrincipalType.TEAM, 20L, AccessPermission.USE)));
// Only #2 leads a team; leaderUserIds is what the controller passes to the bulk method.
lenient().when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
leaderUserIds = Set.of(2L);
}
@Test
void bulkMatchesPerUserForAdminsAndTeamLeadsPolicy() {
assertParity(DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS);
}
@Test
void bulkMatchesPerUserForOrgAllPolicy() {
assertParity(DefaultAccessPolicy.ORG_ALL);
}
@Test
void bulkMatchesPerUserForExplicitOnlyPolicy() {
assertParity(DefaultAccessPolicy.EXPLICIT_ONLY);
}
/** SaaS no-leak: ORG_ALL grants nobody deployment-wide when the resolver forbids it. */
@Test
void orgAllDoesNotLeakDeploymentWideWhenResolverForbidsIt() {
DefaultPrincipalResolver base = new DefaultPrincipalResolver();
PrincipalResolver saasLikeResolver =
new PrincipalResolver() {
@Override
public Set<PrincipalRef> principalsOf(User user) {
return base.principalsOf(user);
}
// allowsDeploymentWideAccess() inherits the interface default (false) = SaaS.
};
service = new ResourceAccessService(grantRepository, teamLeadLookup, saasLikeResolver);
ReflectionTestUtils.setField(service, "portalDefaultPolicy", DefaultAccessPolicy.ORG_ALL);
lenient()
.when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
.thenReturn(List.of());
User adminUser = user(1L, null, Role.ADMIN.getRoleId());
User plainMember = user(5L, 10L, Role.USER.getRoleId());
Set<Long> bulk = service.usersWithPortalAccess(List.of(adminUser, plainMember), Set.of());
assertThat(bulk).contains(1L).doesNotContain(5L);
assertThat(service.canAccessPortal(plainMember))
.as("ORG_ALL must not grant a plain member deployment-wide on a SaaS-like resolver")
.isFalse();
assertThat(service.canAccessPortal(adminUser)).isTrue();
}
private void assertParity(DefaultAccessPolicy policy) {
setUp(policy);
Set<Long> bulk = service.usersWithPortalAccess(everyone, leaderUserIds);
for (User user : everyone) {
boolean authoritative = service.canAccessPortal(user);
assertThat(bulk.contains(user.getId()))
.as(
"policy=%s user=%d bulk should equal canAccessPortal(%s)",
policy, user.getId(), authoritative)
.isEqualTo(authoritative);
}
}
private User user(Long id, Long teamId, String authority) {
User user = new User();
user.setId(id);
user.setUsername("user-" + id);
new Authority(authority, user);
if (teamId != null) {
Team team = new Team();
team.setId(teamId);
team.setName("team-" + teamId);
user.setTeam(team);
}
return user;
}
private ResourceGrant grant(PrincipalType type, Long principalId, AccessPermission permission) {
ResourceGrant grant = new ResourceGrant();
grant.setResourceType(ResourceType.PORTAL);
grant.setResourceId("");
grant.setPrincipalType(type);
grant.setPrincipalId(principalId);
grant.setPermission(permission);
return grant;
}
}
@@ -1,255 +0,0 @@
package stirling.software.proprietary.controller.api;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.security.core.Authentication;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.access.service.DefaultPrincipalResolver;
import stirling.software.proprietary.access.service.MembershipTeamLeadLookup;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
import tools.jackson.databind.ObjectMapper;
/** Shared seeding, wiring, and statement-count measurement for the admin-roster query tests. */
class AdminSettingsPerfHarness {
static final Duration SESSION_TIMEOUT = Duration.ofMinutes(30);
private static final Instant STALE = Instant.now().minus(Duration.ofHours(2));
private static final Instant FRESH = Instant.now().minus(Duration.ofMinutes(2));
record Measure(int users, long statements, long updates, long inserts, long millis) {}
private final UserRepository userRepository;
private final SessionRepository sessionRepository;
private final TeamRepository teamRepository;
private final TeamMembershipRepository teamMembershipRepository;
private final ResourceGrantRepository resourceGrantRepository;
private final EntityManager em;
private final EntityManagerFactory emf;
AdminSettingsPerfHarness(
UserRepository userRepository,
SessionRepository sessionRepository,
TeamRepository teamRepository,
TeamMembershipRepository teamMembershipRepository,
ResourceGrantRepository resourceGrantRepository,
EntityManager em,
EntityManagerFactory emf) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
this.teamRepository = teamRepository;
this.teamMembershipRepository = teamMembershipRepository;
this.resourceGrantRepository = resourceGrantRepository;
this.em = em;
this.emf = emf;
}
Measure seedAndMeasure(
ProprietaryUIDataController controller, Authentication auth, int userCount) {
wipe();
seed(userCount);
em.flush();
em.clear();
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
long t0 = System.nanoTime();
var response = controller.getAdminSettingsData(auth);
int users = response.getBody().getUsers().size();
em.flush(); // materialise any writes the GET issued so they are counted
long millis = (System.nanoTime() - t0) / 1_000_000;
return new Measure(
users,
stats.getPrepareStatementCount(),
stats.getEntityUpdateCount(),
stats.getEntityInsertCount(),
millis);
}
void wipe() {
// Detach anything a prior measure left managed, then delete children before parents.
em.clear();
teamMembershipRepository.deleteAllInBatch();
sessionRepository.deleteAllInBatch();
resourceGrantRepository.deleteAllInBatch();
// Not deleteAllInBatch: a bulk DELETE bypasses the User->authorities/user_settings cascade.
userRepository.deleteAll();
em.flush();
teamRepository.deleteAllInBatch();
em.flush();
em.clear();
}
void seed(int userCount) {
int teamCount = Math.max(1, userCount / 40);
List<Team> teams = new ArrayList<>(teamCount);
for (int i = 0; i < teamCount; i++) {
Team team = new Team();
team.setName("team-" + i);
teams.add(team);
}
List<Team> savedTeams = teamRepository.saveAll(teams);
em.flush();
List<User> users = new ArrayList<>(userCount);
List<SessionEntity> sessions = new ArrayList<>(userCount);
for (int i = 0; i < userCount; i++) {
User user = new User();
String username = "user-" + i;
user.setUsername(username);
user.setEnabled(true);
user.setTeam(savedTeams.get(i % teamCount));
new Authority(i == 0 ? Role.ADMIN.getRoleId() : Role.USER.getRoleId(), user);
Map<String, String> settings = new HashMap<>();
settings.put("language", "en-GB");
if (i % 5 == 0) {
settings.put("mfaSecret", "SECRET-" + i);
}
user.setSettings(settings);
users.add(user);
SessionEntity session = new SessionEntity();
session.setSessionId(UUID.randomUUID().toString());
session.setPrincipalName(username);
// ~30% of sessions are past the timeout.
session.setLastRequest(i % 10 < 3 ? STALE : FRESH);
session.setExpired(false);
sessions.add(session);
}
List<User> savedUsers = userRepository.saveAll(users);
sessionRepository.saveAll(sessions);
em.flush();
List<TeamMembership> memberships = new ArrayList<>();
for (int i = 0; i < userCount; i++) {
if (i % 10 == 0) {
TeamMembership membership = new TeamMembership();
membership.setTeam(savedUsers.get(i).getTeam());
membership.setUser(savedUsers.get(i));
membership.setRole(TeamRole.LEADER);
membership.setInvitedAt(LocalDateTime.now());
memberships.add(membership);
}
}
teamMembershipRepository.saveAll(memberships);
em.flush();
}
ProprietaryUIDataController buildController() {
ApplicationProperties applicationProperties =
mock(ApplicationProperties.class, RETURNS_DEEP_STUBS);
SessionPersistentRegistry sessionRegistry =
new SessionPersistentRegistry(sessionRepository);
ReflectionTestUtils.setField(
sessionRegistry, "defaultMaxInactiveInterval", SESSION_TIMEOUT);
ResourceAccessService resourceAccessService =
new ResourceAccessService(
resourceGrantRepository,
new MembershipTeamLeadLookup(teamMembershipRepository),
new DefaultPrincipalResolver());
ReflectionTestUtils.setField(
resourceAccessService,
"portalDefaultPolicy",
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS);
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
UserLicenseSettings licenseSettings = mock(UserLicenseSettings.class);
lenient().when(licenseSettings.getLicenseMaxUsers()).thenReturn(0);
lenient().when(licenseSettingsService.getSettings()).thenReturn(licenseSettings);
lenient().when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(100_000);
lenient().when(licenseSettingsService.getAvailableUserSlots()).thenReturn(100_000L);
lenient().when(licenseSettingsService.getDisplayGrandfatheredCount()).thenReturn(0);
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
lenient().when(loginAttemptService.getAllBlockedUsers()).thenReturn(new ArrayList<>());
return new ProprietaryUIDataController(
applicationProperties,
mock(AuditConfigurationProperties.class),
sessionRegistry,
userRepository,
teamRepository,
teamMembershipRepository,
sessionRepository,
mock(DatabaseServiceInterface.class),
mock(ObjectMapper.class),
false,
licenseSettingsService,
mock(PersistentAuditEventRepository.class),
mock(MfaService.class),
loginAttemptService,
resourceAccessService);
}
Authentication adminAuth() {
Authentication auth = mock(Authentication.class);
lenient().when(auth.getName()).thenReturn("user-0");
return auth;
}
User mkUser(String username, Team team, String authority, Map<String, String> settings) {
User user = new User();
user.setUsername(username);
user.setEnabled(true);
user.setTeam(team);
new Authority(authority, user);
user.setSettings(new HashMap<>(settings));
return user;
}
TeamRepository teams() {
return teamRepository;
}
UserRepository users() {
return userRepository;
}
EntityManager em() {
return em;
}
}
@@ -1,194 +0,0 @@
package stirling.software.proprietary.controller.api;
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.lenient;
import static org.mockito.Mockito.mock;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.security.core.Authentication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceContext;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
/** Admin roster issues a constant query count regardless of size (H2). */
@DataJpaTest
class AdminSettingsQueryPerfTest {
@Autowired private UserRepository userRepository;
@Autowired private SessionRepository sessionRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private TeamMembershipRepository teamMembershipRepository;
@Autowired private ResourceGrantRepository resourceGrantRepository;
@Autowired private EntityManagerFactory emf;
@PersistenceContext private EntityManager em;
private AdminSettingsPerfHarness harness() {
return new AdminSettingsPerfHarness(
userRepository,
sessionRepository,
teamRepository,
teamMembershipRepository,
resourceGrantRepository,
em,
emf);
}
@Test
void queryCountDoesNotScaleWithUsers() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Authentication admin = harness.adminAuth();
Measure small = harness.seedAndMeasure(controller, admin, 150);
Measure large = harness.seedAndMeasure(controller, admin, 750);
System.out.printf(
"%n[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n",
small.users(), small.statements(), small.updates(), small.millis());
System.out.printf(
"[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n",
large.users(), large.statements(), large.updates(), large.millis());
long delta = large.statements() - small.statements();
System.out.printf(
"[admin-settings scaling] +%d users cost +%d statements%n",
large.users() - small.users(), delta);
assertTrue(
delta <= 40,
"admin-settings issues per-user queries: adding "
+ (large.users() - small.users())
+ " users added "
+ delta
+ " SQL statements (expected <= 40). The roster endpoint still scales O(N).");
assertEquals(
0,
large.updates(),
"admin-settings performed " + large.updates() + " row UPDATEs during a read (GET)");
}
@Test
void headlineBenchmark() {
int n = Integer.getInteger("adminBenchUsers", 2000);
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Measure m = harness.seedAndMeasure(controller, harness.adminAuth(), n);
System.out.printf(
"%n==== admin-settings headline (N=%d users) ====%n"
+ " SQL statements : %d%n"
+ " row UPDATEs : %d%n"
+ " row INSERTs : %d%n"
+ " wall-clock : %d ms%n"
+ " statements/user: %.2f%n"
+ "==============================================%n",
m.users(),
m.statements(),
m.updates(),
m.inserts(),
m.millis(),
(double) m.statements() / n);
assertEquals(n, m.users(), "roster should return every seeded (non-internal) user");
}
@Test
void rosterExcludesInternalAccountsAndMasksSecrets() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
harness.wipe();
Team acme = new Team();
acme.setName("acme");
Team internal = new Team();
internal.setName(TeamService.INTERNAL_TEAM_NAME);
List<Team> savedTeams = harness.teams().saveAll(List.of(acme, internal));
harness.em().flush();
User adminUser =
harness.mkUser("admin", savedTeams.get(0), Role.ADMIN.getRoleId(), Map.of());
User mfaUser =
harness.mkUser(
"mfa-user",
savedTeams.get(0),
Role.USER.getRoleId(),
Map.of("mfaSecret", "TOPSECRET", "language", "fr"));
User apiUser =
harness.mkUser(
"internal-api",
savedTeams.get(0),
Role.INTERNAL_API_USER.getRoleId(),
Map.of());
User internalTeamUser =
harness.mkUser("internal-team", savedTeams.get(1), Role.USER.getRoleId(), Map.of());
harness.users().saveAll(List.of(adminUser, mfaUser, apiUser, internalTeamUser));
harness.em().flush();
harness.em().clear();
Authentication auth = mock(Authentication.class);
lenient().when(auth.getName()).thenReturn("admin");
ProprietaryUIDataController.AdminSettingsData data =
controller.getAdminSettingsData(auth).getBody();
Set<String> usernames =
data.getUsers().stream()
.map(AdminUserSummary::getUsername)
.collect(Collectors.toSet());
assertTrue(usernames.contains("admin"));
assertTrue(usernames.contains("mfa-user"));
assertFalse(usernames.contains("internal-api"), "internal-api user must be excluded");
assertFalse(usernames.contains("internal-team"), "internal-team user must be excluded");
assertEquals(2, data.getTotalUsers());
Map<String, String> mfaSettings = data.getUserSettings().get("mfa-user");
assertEquals("********", mfaSettings.get("mfaSecret"), "mfaSecret must be masked");
assertEquals("fr", mfaSettings.get("language"), "non-secret settings preserved");
AdminUserSummary adminSummary =
data.getUsers().stream()
.filter(u -> "admin".equals(u.getUsername()))
.findFirst()
.orElseThrow();
assertTrue(adminSummary.isPortalAccess(), "admin should have portal access");
}
@SpringBootConfiguration
@EntityScan(
basePackages = {
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.access.model"
})
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.access.repository"
})
static class TestApp {}
}
@@ -1,109 +0,0 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceContext;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
/** Admin-roster queries + index DDL on real Postgres (skipped without Docker). */
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers(disabledWithoutDocker = true)
class AdminSettingsQueryPostgresTest {
@Container
static PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
registry.add("spring.jpa.properties.hibernate.default_batch_fetch_size", () -> "100");
}
@Autowired private UserRepository userRepository;
@Autowired private SessionRepository sessionRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private TeamMembershipRepository teamMembershipRepository;
@Autowired private ResourceGrantRepository resourceGrantRepository;
@Autowired private EntityManagerFactory emf;
@PersistenceContext private EntityManager em;
private AdminSettingsPerfHarness harness() {
return new AdminSettingsPerfHarness(
userRepository,
sessionRepository,
teamRepository,
teamMembershipRepository,
resourceGrantRepository,
em,
emf);
}
@Test
void newRosterQueriesRunOnPostgresWithConstantScaling() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Authentication admin = harness.adminAuth();
Measure small = harness.seedAndMeasure(controller, admin, 100);
Measure large = harness.seedAndMeasure(controller, admin, 400);
System.out.printf(
"%n[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n",
small.users(), small.statements(), small.updates(), small.millis());
System.out.printf(
"[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n",
large.users(), large.statements(), large.updates(), large.millis());
assertEquals(400, large.users(), "roster returns every seeded user on Postgres");
assertTrue(
large.statements() - small.statements() <= 40,
"roster must not scale per-user on Postgres (delta="
+ (large.statements() - small.statements())
+ ")");
assertEquals(0, large.updates(), "no writes during the GET on Postgres");
}
@SpringBootConfiguration
@EntityScan(
basePackages = {
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.access.model"
})
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.access.repository"
})
static class TestApp {}
}
@@ -141,8 +141,7 @@ class ProprietaryUIDataControllerMoreTest {
void singleAdminFirstLogin() {
User admin = normalUser(1L, "admin");
admin.setFirstLogin(true);
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(1L);
when(userRepository.findAll()).thenReturn(List.of(admin));
when(userRepository.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(admin));
ResponseEntity<LoginData> response = controller.getLoginData();
@@ -155,8 +154,7 @@ class ProprietaryUIDataControllerMoreTest {
@Test
@DisplayName("does not flag setup when a normal user exists")
void normalUserNoSetup() {
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(1L);
when(userRepository.findAll()).thenReturn(List.of(normalUser(1L, "bob")));
ResponseEntity<LoginData> response = controller.getLoginData();
@@ -254,9 +252,11 @@ class ProprietaryUIDataControllerMoreTest {
@DisplayName("aggregates users, teams and license limits")
void aggregates() {
User user = normalUser(1L, "bob");
when(userRepository.findAllWithTeamAndAuthorities())
when(userRepository.findAllWithTeam())
.thenReturn(new java.util.ArrayList<>(List.of(user)));
when(sessionPersistentRegistry.getMaxInactiveInterval()).thenReturn(3600);
when(sessionPersistentRegistry.findLatestSession("bob")).thenReturn(Optional.empty());
when(userRepository.findByIdWithSettings(1L)).thenReturn(Optional.of(user));
when(teamRepository.findAll()).thenReturn(List.of());
when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(10);
@@ -310,7 +310,7 @@ class ProprietaryUIDataControllerMoreTest {
team.setName("Engineering");
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
when(userRepository.findAllByTeamId(5L)).thenReturn(List.of());
when(userRepository.findAllWithTeamAndAuthorities()).thenReturn(List.of());
when(userRepository.findAllWithTeam()).thenReturn(List.of());
when(sessionRepository.findLatestSessionByTeamId(5L))
.thenReturn(Collections.emptyList());
@@ -3,6 +3,7 @@ package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -92,7 +93,7 @@ class ProprietaryUIDataControllerTest {
@Test
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())).thenReturn(0L);
when(userRepository.findAll()).thenReturn(Collections.emptyList());
ResponseEntity<LoginData> response = controller.getLoginData();
@@ -356,7 +356,6 @@ text = "Diese Berechtigungen kontrollieren, was Benutzer mit der PDF machen kön
title = "Berechtigungen ändern"
[addStamp]
preview = "Stempelvorschau"
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
[AddStampRequest]
@@ -1843,15 +1842,6 @@ export = "Export"
accessDenied = "Zugriff verweigert"
insufficientPermissions = "Sie haben keine Berechtigung, diese Aktion auszuführen."
[auth.callback]
completing = "Authentifizierung wird abgeschlossen"
invalidToken = "OAuth-Anmeldung fehlgeschlagen ungültiges Token."
missingToken = "OAuth-Anmeldung fehlgeschlagen kein Token empfangen."
oauthFailed = "OAuth-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
pleaseWait = "Bitte warten Sie, während wir Ihre Anmeldung abschließen."
signedOut = "Sie wurden abgemeldet. Bitte melden Sie sich erneut an."
windowMayClose = "Sie können dieses Fenster schließen, sobald der Vorgang abgeschlossen ist."
[auth.displayName]
guest = "Gast"
user = "Benutzer"
@@ -2834,7 +2824,6 @@ next = "Weiter"
preview = "Vorschau"
previous = "Zurück"
refresh = "Aktualisieren"
remaining = "Verbleibend"
retry = "Wiederholen"
save = "Save"
@@ -4083,17 +4072,10 @@ discord = "Discord"
issues = "GitHub"
[formFill]
allSaved = "Alles gespeichert"
extractCsvError = "CSV konnte nicht extrahiert werden"
extractXlsxError = "XLSX konnte nicht extrahiert werden"
flattenAfterFilling = "Nach dem Ausfüllen ebnen"
requiredAbbreviation = "erf."
requiredFieldsError = "Bitte füllen Sie alle Pflichtfelder aus"
rescanFields = "Felder erneut scannen"
rescanFormFields = "Formularfelder erneut scannen"
save = "Speichern"
saveShortcut = "Strg+S zum Speichern"
unsavedChanges = "Nicht gespeicherte Änderungen"
[formFill.sidebar]
close = "Seitenleiste schließen"
@@ -5014,21 +4996,7 @@ titleWithOs = "Herunterladen für {{osLabel}}"
[onboarding.mfa]
authenticationCode = "Authentifizierungscode"
description = "Schützen Sie Ihr Konto, indem Sie eine Authenticator-App verknüpfen. Scannen Sie den QR-Code oder geben Sie den Einrichtungsschlüssel ein und bestätigen Sie anschließend den sechsstelligen Code."
enable = "MFA aktivieren"
enableError = "Die Multi-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfen Sie den Code und versuchen Sie es erneut."
enterCode = "Geben Sie den sechsstelligen Code aus Ihrer App ein."
enterCodeError = "Geben Sie den Authentifizierungscode ein, um fortzufahren."
logout = "Abmelden"
openAuthenticator = "Öffnen Sie Google Authenticator, Authy oder 1Password."
qrCodeLoading = "Ihr QR-Code wird erstellt…"
regenerateQrCode = "QR-Code neu erstellen"
scanQrCode = "Scannen Sie den QR-Code oder geben Sie den unten stehenden Einrichtungsschlüssel ein."
setupError = "Die Einrichtung der Zwei-Faktor-Authentifizierung konnte nicht gestartet werden. Bitte versuchen Sie es erneut."
setupKey = "Einrichtungsschlüssel (manuelle Eingabe)"
stepByStep = "Schritt für Schritt"
success = "MFA wurde aktiviert. Sie können jetzt fortfahren."
title = "Einrichtung der Multi-Faktor-Authentifizierung"
[onboarding.planOverview]
adminBodyLoginDisabled = "Sobald Sie den Login-Modus aktivieren, können Sie Nutzer verwalten, Einstellungen konfigurieren und die Servergesundheit überwachen. Die ersten <strong>{{freeTierLimit}}</strong> Personen auf Ihrem Server nutzen Stirling kostenlos."
@@ -9142,11 +9110,6 @@ security = "Sicherheit"
telegram = "Telegram"
title = "Sicherheit & Authentifizierung"
[settings.signOut]
confirm = "Möchten Sie sich wirklich abmelden?"
submit = "Abmelden"
title = "Abmelden"
[settings.team]
title = "Team"
@@ -9763,9 +9726,6 @@ uploadButton = "Auf Server hochladen"
title = "Stirling-PDF Survey"
[swagger]
clickHere = "hier klicken"
fallback = "Falls die Seite nicht automatisch geöffnet wurde,"
opening = "Swagger UI wird in einem neuen Tab geöffnet..."
tags = "API,Dokumentation,Swagger,Endpunkte,Entwicklung"
title = "API-Dokumentation"
@@ -10321,11 +10281,9 @@ title = "Attachments"
[viewer.bookmarks]
bookmarkTitle = "Lesezeichentitel"
bookmarkTitleRequired = "Ein Lesezeichentitel ist erforderlich"
closeSidebar = "Seitenleiste für Lesezeichen schließen"
collapseAll = "Alle Lesezeichen einklappen"
expandAll = "Alle Lesezeichen ausklappen"
searchPlaceholder = "Lesezeichen durchsuchen"
[viewer.comments]
addComment = "Kommentar hinzufügen"
@@ -356,7 +356,6 @@ text = "These permissions control what users can do with the PDF. Most effective
title = "Change Permissions"
[addStamp]
preview = "Preview Stamp"
tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original"
[AddStampRequest]
@@ -1843,15 +1842,6 @@ export = "Export"
accessDenied = "Access Denied"
insufficientPermissions = "You do not have permission to perform this action."
[auth.callback]
completing = "Completing authentication"
invalidToken = "OAuth login failed - invalid token."
missingToken = "OAuth login failed - no token received."
oauthFailed = "OAuth login failed. Please try again."
pleaseWait = "Please wait while we finish signing you in."
signedOut = "You have been signed out. Please sign in again."
windowMayClose = "You can close this window once it completes."
[auth.displayName]
guest = "Guest"
user = "User"
@@ -2834,7 +2824,6 @@ next = "Next"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
remaining = "Remaining"
retry = "Retry"
save = "Save"
@@ -4083,17 +4072,10 @@ discord = "Discord"
issues = "GitHub"
[formFill]
allSaved = "All saved"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
flattenAfterFilling = "Flatten after filling"
requiredAbbreviation = "req"
requiredFieldsError = "Please fill in all required fields"
rescanFields = "Re-scan fields"
rescanFormFields = "Re-scan form fields"
save = "Save"
saveShortcut = "Ctrl+S to save"
unsavedChanges = "Unsaved changes"
[formFill.sidebar]
close = "Close sidebar"
@@ -5034,21 +5016,7 @@ titleWithOs = "Download for {{osLabel}}"
[onboarding.mfa]
authenticationCode = "Authentication code"
description = "Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish."
enable = "Enable MFA"
enableError = "Unable to enable two-factor authentication. Check the code and try again."
enterCode = "Enter the 6-digit code from your app."
enterCodeError = "Enter the authentication code to continue."
logout = "Logout"
openAuthenticator = "Open Google Authenticator, Authy, or 1Password."
qrCodeLoading = "Generating your QR code…"
regenerateQrCode = "Regenerate QR code"
scanQrCode = "Scan the QR code or enter the setup key below."
setupError = "Unable to start two-factor setup. Please try again."
setupKey = "Setup key (manual entry)"
stepByStep = "Step-by-step"
success = "MFA has been enabled. You can now continue."
title = "Multi-Factor Authentication Setup"
[onboarding.planOverview]
adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
@@ -9210,11 +9178,6 @@ security = "Security"
telegram = "Telegram"
title = "Security & Authentication"
[settings.signOut]
confirm = "Are you sure you want to sign out?"
submit = "Sign out"
title = "Sign out"
[settings.team]
title = "Team"
@@ -9831,9 +9794,6 @@ uploadButton = "Upload to Server"
title = "Stirling-PDF Survey"
[swagger]
clickHere = "click here"
fallback = "If it didn't open automatically,"
opening = "Opening Swagger UI in a new tab..."
tags = "api,documentation,swagger,endpoints,development"
title = "API Documentation"
@@ -10402,11 +10362,9 @@ title = "Attachments"
[viewer.bookmarks]
bookmarkTitle = "Bookmark title"
bookmarkTitleRequired = "Bookmark title is required"
closeSidebar = "Close bookmarks sidebar"
collapseAll = "Collapse all bookmarks"
expandAll = "Expand all bookmarks"
searchPlaceholder = "Search bookmarks"
[viewer.comments]
addComment = "Add comment"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -26,7 +26,6 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH, withBasePath } from "@app/constants/app";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
import i18n from "@app/i18n";
interface MFASetupSlideProps {
onMfaSetupComplete?: () => void;
@@ -63,15 +62,12 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"onboarding.mfa.setupError",
"Unable to start two-factor setup. Please try again.",
),
"Unable to start two-factor setup. Please try again.",
);
} finally {
setMfaLoading(false);
}
}, [t]);
}, []);
useEffect(() => {
setupCompleteRef.current = setupComplete;
@@ -100,12 +96,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(
t(
"onboarding.mfa.enterCodeError",
"Enter the authentication code to continue.",
),
);
setMfaError("Enter the authentication code to continue.");
return;
}
@@ -119,16 +110,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"onboarding.mfa.enableError",
"Unable to enable two-factor authentication. Check the code and try again.",
),
"Unable to enable two-factor authentication. Check the code and try again.",
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete, t],
[mfaSetupCode, onMfaSetupComplete],
);
const isReady = Boolean(mfaSetupData);
@@ -150,30 +138,15 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<Stack gap="xs">
<Text size="sm" fw={600}>
{t("onboarding.mfa.stepByStep", "Step-by-step")}
Step-by-step
</Text>
<ol className={styles.mfaSteps}>
<li>
{t(
"onboarding.mfa.openAuthenticator",
"Open Google Authenticator, Authy, or 1Password.",
)}
</li>
<li>
{t(
"onboarding.mfa.scanQrCode",
"Scan the QR code or enter the setup key below.",
)}
</li>
<li>
{t(
"onboarding.mfa.enterCode",
"Enter the 6-digit code from your app.",
)}
</li>
<li>Open Google Authenticator, Authy, or 1Password.</li>
<li>Scan the QR code or enter the setup key below.</li>
<li>Enter the 6-digit code from your app.</li>
</ol>
<Text size="xs" c="dimmed">
{t("onboarding.mfa.setupKey", "Setup key (manual entry)")}
Setup key (manual entry)
</Text>
<TextInput
value={mfaSetupData.secret ?? ""}
@@ -190,10 +163,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"onboarding.mfa.description",
"Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish.",
)}
Secure your account by linking an authenticator app. Scan the QR
code or enter the setup key, then confirm the 6-digit code to
finish.
</Text>
{mfaError && (
@@ -243,7 +215,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
onClick={fetchMfaSetup}
disabled={mfaLoading || submitting || setupComplete}
>
{t("onboarding.mfa.regenerateQrCode", "Regenerate QR code")}
Regenerate QR code
</Button>
<Button
type="submit"
@@ -252,19 +224,16 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
!isReady || setupComplete || mfaSetupCode.length < 6
}
>
{t("onboarding.mfa.enable", "Enable MFA")}
Enable MFA
</Button>
<Button variant="secondary" type="button" onClick={onLogout}>
{t("onboarding.mfa.logout", "Logout")}
Logout
</Button>
</Group>
{setupComplete && (
<Alert color="green" variant="light">
{t(
"onboarding.mfa.success",
"MFA has been enabled. You can now continue.",
)}
MFA has been enabled. You can now continue.
</Alert>
)}
</Stack>
@@ -280,7 +249,7 @@ export default function MFASetupSlide({
}: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: i18n.t("onboarding.mfa.title", "Multi-Factor Authentication Setup"),
title: "Multi-Factor Authentication Setup",
body: <MFASetupContent onMfaSetupComplete={onMfaSetupComplete} />,
background: {
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
@@ -12,7 +12,6 @@ import {
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
type Props = {
parameters: AddStampParameters;
@@ -30,7 +29,6 @@ export default function StampPreview({
file,
showQuickGrid,
}: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState<{
width: number;
@@ -368,9 +366,7 @@ export default function StampPreview({
<div>
<div className={styles.previewHeader}>
<div className={styles.divider} />
<div className={styles.previewLabel}>
{t("addStamp.preview", "Preview Stamp")}
</div>
<div className={styles.previewLabel}>Preview Stamp</div>
</div>
<div
ref={containerRef}
@@ -328,12 +328,7 @@ export const BookmarkSidebar = ({
const handleSubmitAddBookmark = useCallback(async () => {
const title = newBookmarkTitle.trim();
if (!title) {
setAddBookmarkError(
t(
"viewer.bookmarks.bookmarkTitleRequired",
"Bookmark title is required",
),
);
setAddBookmarkError("Bookmark title is required");
return;
}
// Resolve the file the viewer is currently displaying. activeFileId
@@ -700,10 +695,7 @@ export const BookmarkSidebar = ({
"viewer.bookmarks.expandAll",
"Expand all bookmarks",
)}
title={t(
"viewer.bookmarks.expandAll",
"Expand all bookmarks",
)}
title="Expand all"
>
<LocalIcon
icon="unfold-more"
@@ -720,10 +712,7 @@ export const BookmarkSidebar = ({
"viewer.bookmarks.collapseAll",
"Collapse all bookmarks",
)}
title={t(
"viewer.bookmarks.collapseAll",
"Collapse all bookmarks",
)}
title="Collapse all"
>
<LocalIcon
icon="unfold-less"
@@ -743,10 +732,7 @@ export const BookmarkSidebar = ({
"viewer.bookmarks.closeSidebar",
"Close bookmarks sidebar",
)}
title={t(
"viewer.bookmarks.closeSidebar",
"Close bookmarks sidebar",
)}
title="Close bookmarks"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
@@ -760,10 +746,7 @@ export const BookmarkSidebar = ({
>
<TextInput
value={searchTerm}
placeholder={t(
"viewer.bookmarks.searchPlaceholder",
"Search bookmarks",
)}
placeholder="Search bookmarks"
onChange={(event) => setSearchTerm(event.currentTarget.value)}
leftSection={
<LocalIcon icon="search" width="1.1rem" height="1.1rem" />
@@ -856,10 +839,7 @@ export const BookmarkSidebar = ({
</Text>
<TextInput
size="xs"
placeholder={t(
"viewer.bookmarks.bookmarkTitle",
"Bookmark title",
)}
placeholder="Bookmark title"
aria-label={t(
"viewer.bookmarks.bookmarkTitle",
"Bookmark title",
@@ -350,7 +350,7 @@ export function LayerSidebar({
onClick={showAll}
disabled={allVisible || isApplying}
aria-label={t("viewer.layers.showAll", "Show all layers")}
title={t("viewer.layers.showAll", "Show all layers")}
title="Show all"
>
<VisibilityIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -360,7 +360,7 @@ export function LayerSidebar({
onClick={hideAll}
disabled={allHidden || isApplying}
aria-label={t("viewer.layers.hideAll", "Hide all layers")}
title={t("viewer.layers.hideAll", "Hide all layers")}
title="Hide all"
>
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -372,7 +372,7 @@ export function LayerSidebar({
size="sm"
onClick={toggleLayerSidebar}
aria-label={t("viewer.layers.closeSidebar", "Close layers sidebar")}
title={t("viewer.layers.closeSidebar", "Close layers sidebar")}
title="Close layers"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
@@ -193,10 +193,7 @@ export function ThumbnailSidebar({
"viewer.thumbnails.closeSidebar",
"Close thumbnails sidebar",
)}
title={t(
"viewer.thumbnails.closeSidebar",
"Close thumbnails sidebar",
)}
title="Close thumbnails"
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
+3 -5
View File
@@ -1,10 +1,8 @@
import React, { useEffect } from "react";
import { BaseToolProps } from "@app/types/tool";
import { withBasePath } from "@app/constants/app";
import { useTranslation } from "react-i18next";
const SwaggerUI: React.FC<BaseToolProps> = () => {
const { t } = useTranslation();
useEffect(() => {
// Redirect to Swagger UI
window.open(withBasePath("/swagger-ui/5.21.0/index.html"), "_blank");
@@ -12,15 +10,15 @@ const SwaggerUI: React.FC<BaseToolProps> = () => {
return (
<div style={{ textAlign: "center", padding: "2rem" }}>
<p>{t("swagger.opening", "Opening Swagger UI in a new tab...")}</p>
<p>Opening Swagger UI in a new tab...</p>
<p>
{t("swagger.fallback", "If it didn't open automatically,")}{" "}
If it didn't open automatically,{" "}
<a
href={withBasePath("/swagger-ui/5.21.0/index.html")}
target="_blank"
rel="noopener noreferrer"
>
{t("swagger.clickHere", "click here")}
click here
</a>
</p>
</div>
@@ -204,7 +204,7 @@ const FormFill = (_props: BaseToolProps) => {
setTimeout(() => URL.revokeObjectURL(url), 250);
} catch (err) {
console.error("[FormFill] CSV extraction failed:", err);
setSaveError(t("formFill.extractCsvError", "Failed to extract CSV"));
setSaveError("Failed to extract CSV");
} finally {
setExtracting(false);
}
@@ -223,7 +223,7 @@ const FormFill = (_props: BaseToolProps) => {
setTimeout(() => URL.revokeObjectURL(url), 250);
} catch (err) {
console.error("[FormFill] XLSX extraction failed:", err);
setSaveError(t("formFill.extractXlsxError", "Failed to extract XLSX"));
setSaveError("Failed to extract XLSX");
} finally {
setExtracting(false);
}
@@ -246,9 +246,7 @@ const FormFill = (_props: BaseToolProps) => {
if (!currentFile || !isStirlingFile(currentFile)) return;
if (!validateForm()) {
setSaveError(
t("formFill.requiredFieldsError", "Please fill in all required fields"),
);
setSaveError("Please fill in all required fields");
return;
}
@@ -643,9 +641,7 @@ const FormFill = (_props: BaseToolProps) => {
{field.label || field.name}
</span>
{field.required && (
<span className={styles.fieldRequired}>
{t("formFill.requiredAbbreviation", "req")}
</span>
<span className={styles.fieldRequired}>req</span>
)}
</div>
@@ -687,10 +683,10 @@ const FormFill = (_props: BaseToolProps) => {
<span className={styles.unsavedDot} />
)}
{formState.isDirty || flattenChanged
? t("formFill.unsavedChanges", "Unsaved changes")
: t("formFill.allSaved", "All saved")}
? "Unsaved changes"
: "All saved"}
</span>
<span>{t("formFill.saveShortcut", "Ctrl+S to save")}</span>
<span>Ctrl+S to save</span>
</div>
)}
</>
+1 -3
View File
@@ -1,6 +1,5 @@
import { useState, type ReactNode } from "react";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import "@app/ui/CodeBlock.css";
export type CodeLang =
@@ -34,7 +33,6 @@ export function CodeBlock({
maxHeight = 400,
className,
}: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
async function copy() {
@@ -66,7 +64,7 @@ export function CodeBlock({
onClick={copy}
aria-label="Copy code"
>
{copied ? t("common.copied", "Copied!") : t("common.copy", "Copy")}
{copied ? "Copied" : "Copy"}
</Button>
)}
</div>
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
import { Button, Modal } from "@app/ui";
import "@portal/views/AgentBuilder.css";
@@ -59,7 +58,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
/>
<span className="portal-agents__dropzone-icon" aria-hidden>
<FileUploadRounded style={{ fontSize: "1.5rem" }} />
</span>
<span className="portal-agents__dropzone-text">
{fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")}
@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import LockRounded from "@mui/icons-material/LockRounded";
import { StatusBadge, Table, type TableColumn } from "@app/ui";
import { type Extraction, type ReviewDocument } from "@portal/api/documents";
import {
@@ -56,7 +55,7 @@ export function DocumentExtractions({
return (
<div className="portal-documents__masked">
<span className="portal-documents__masked-icon" aria-hidden>
<LockRounded style={{ fontSize: "1.5rem" }} />
🔒
</span>
<p className="portal-documents__masked-text">
{t("portal.documents.extractions.masked")}
@@ -1,6 +1,4 @@
import { useTranslation } from "react-i18next";
import ScheduleRounded from "@mui/icons-material/ScheduleRounded";
import LockRounded from "@mui/icons-material/LockRounded";
import { Banner, Button } from "@app/ui";
import { formatCountdown } from "@portal/components/documents/format";
@@ -28,7 +26,7 @@ export function ElevationBanner({
return (
<Banner
tone="success"
icon={<ScheduleRounded style={{ fontSize: "1.1rem" }} />}
icon={<span aria-hidden></span>}
title={t("portal.documents.elevation.active.title", {
time: formatCountdown(secondsLeft),
})}
@@ -44,7 +42,7 @@ export function ElevationBanner({
return (
<Banner
tone="warning"
icon={<LockRounded style={{ fontSize: "1.1rem" }} />}
icon={<span aria-hidden>🔒</span>}
title={t("portal.documents.elevation.gated.title")}
description={
fourEyes
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import LockRounded from "@mui/icons-material/LockRounded";
import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
import {
classificationTone,
@@ -76,7 +75,7 @@ export function ReviewQueueTable({
title={t("portal.documents.table.sensitiveTitle")}
aria-label={t("portal.documents.table.sensitiveLabel")}
>
<LockRounded style={{ fontSize: "0.95rem" }} />
🔒
</span>
)}
</div>
@@ -1,6 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded";
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
import {
Chip,
@@ -32,7 +31,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
render: (p) => (
<div className="portal-pipelines__name-cell">
<span className="portal-pipelines__pipe-dot" aria-hidden>
<AccountTreeRounded style={{ fontSize: "1.2rem" }} />
</span>
<div className="portal-pipelines__name-text">
<strong>{p.name}</strong>
@@ -1,6 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
@@ -42,7 +41,7 @@ export function AgentBuilder() {
</div>
<Button
onClick={() => setBootstrapOpen(true)}
leftSection={<FileUploadRounded style={{ fontSize: "1.2rem" }} />}
leftSection={<span aria-hidden></span>}
>
{t("portal.agentBuilder.bootstrapFromDocument")}
</Button>
@@ -262,6 +262,6 @@ describe("AuthCallback", () => {
</BrowserRouter>,
);
expect(getByText("auth.callback.completing")).toBeInTheDocument();
expect(getByText("Completing authentication")).toBeInTheDocument();
});
});
@@ -1,6 +1,5 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
consumePostLoginRedirectPath,
springAuth,
@@ -8,7 +7,6 @@ import {
import { markLoginLandingPending } from "@app/utils/loginLanding";
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
import styles from "@app/routes/AuthCallback.module.css";
import i18n from "@app/i18n";
/**
* OAuth Callback Handler
@@ -18,7 +16,6 @@ import i18n from "@app/i18n";
* We extract it, store in localStorage, and redirect to the home page.
*/
export default function AuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const processingRef = useRef(false);
@@ -34,12 +31,7 @@ export default function AuthCallback() {
) {
navigate("/login", {
replace: true,
state: {
error: i18n.t(
"auth.callback.signedOut",
"You have been signed out. Please sign in again.",
),
},
state: { error: "You have been signed out. Please sign in again." },
});
return;
}
@@ -58,12 +50,7 @@ export default function AuthCallback() {
);
navigate("/login", {
replace: true,
state: {
error: i18n.t(
"auth.callback.missingToken",
"OAuth login failed - no token received.",
),
},
state: { error: "OAuth login failed - no token received." },
});
return;
}
@@ -80,12 +67,7 @@ export default function AuthCallback() {
localStorage.removeItem("stirling_jwt");
navigate("/login", {
replace: true,
state: {
error: i18n.t(
"auth.callback.invalidToken",
"OAuth login failed - invalid token.",
),
},
state: { error: "OAuth login failed - invalid token." },
});
return;
}
@@ -111,12 +93,7 @@ export default function AuthCallback() {
);
navigate("/login", {
replace: true,
state: {
error: i18n.t(
"auth.callback.oauthFailed",
"OAuth login failed. Please try again.",
),
},
state: { error: "OAuth login failed. Please try again." },
});
}
};
@@ -128,20 +105,12 @@ export default function AuthCallback() {
<div className={styles.page}>
<div className={styles.card}>
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
<div className={styles.title}>
{t("auth.callback.completing", "Completing authentication")}
</div>
<div className={styles.title}>Completing authentication</div>
<div className={styles.message}>
{t(
"auth.callback.pleaseWait",
"Please wait while we finish signing you in.",
)}
Please wait while we finish signing you in.
</div>
<div className={styles.loadingExtra}>
{t(
"auth.callback.windowMayClose",
"You can close this window once it completes.",
)}
You can close this window once it completes.
</div>
</div>
</div>
@@ -109,9 +109,7 @@ export default function Landing() {
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
{t("common.loading", "Loading...")}
</div>
<div className="text-gray-600">Loading...</div>
</div>
</div>
);
@@ -302,20 +302,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
<Modal
opened={confirmOpen}
onClose={() => setConfirmOpen(false)}
title={t("settings.signOut.title", "Sign out")}
title="Sign out"
centered
zIndex={Z_INDEX_OVER_SETTINGS_MODAL}
>
<div className="confirm-modal-content">
<Text>
{t(
"settings.signOut.confirm",
"Are you sure you want to sign out?",
)}
</Text>
<Text>Are you sure you want to sign out?</Text>
<div className="confirm-modal-buttons">
<Button variant="secondary" onClick={() => setConfirmOpen(false)}>
{t("common.cancel", "Cancel")}
Cancel
</Button>
<Button
accent="danger"
@@ -329,7 +324,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
}
}}
>
{t("settings.signOut.submit", "Sign out")}
Sign out
</Button>
</div>
</div>
@@ -13,7 +13,6 @@ import {
createScale,
} from "@app/components/shared/charts/utils/d3Utils";
import "@app/components/shared/charts/StackedBarChart.css";
import { useTranslation } from "react-i18next";
export default function StackedBarChart({
fractions,
@@ -27,7 +26,6 @@ export default function StackedBarChart({
animationDurationMs = 900,
ariaLabel,
}: StackedBarChartProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const hasAnimatedRef = useRef(false);
@@ -311,7 +309,7 @@ export default function StackedBarChart({
outline: "1px solid var(--api-keys-card-border)",
}}
/>
<Text size="sm">{t("common.remaining", "Remaining")}</Text>
<Text size="sm">Remaining</Text>
</Group>
</Group>
)}
@@ -144,7 +144,7 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
>
<Stack gap="md">
{error && (
<Alert color="red" title={t("common.error", "Error")}>
<Alert color="red" title="Error">
{error}
</Alert>
)}