Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1021c663 |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
]
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
|
||||
+1
-18
@@ -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.
|
||||
*
|
||||
|
||||
+1
-19
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
|
||||
-42
@@ -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,
|
||||
|
||||
+97
-96
@@ -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());
|
||||
|
||||
+1
-6
@@ -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
|
||||
|
||||
-25
@@ -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);
|
||||
}
|
||||
|
||||
-11
@@ -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);
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+1
-11
@@ -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;
|
||||
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
-10
@@ -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);
|
||||
|
||||
+19
-7
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-151
@@ -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;
|
||||
}
|
||||
}
|
||||
-255
@@ -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;
|
||||
}
|
||||
}
|
||||
-194
@@ -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 {}
|
||||
}
|
||||
-109
@@ -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 {}
|
||||
}
|
||||
+6
-6
@@ -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());
|
||||
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user