mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9908c3be | ||
|
|
031477b7b5 | ||
|
|
8725ba66bb | ||
|
|
115a24b16d | ||
|
|
c775fed17d | ||
|
|
ae9d29abf0 | ||
|
|
ddf93d2b1a | ||
|
|
e97f93924e | ||
|
|
8d5b3eb36b | ||
|
|
fe60c94bef | ||
|
|
6758723256 | ||
|
|
2377fed045 | ||
|
|
139d1abd02 | ||
|
|
c17a6805b2 | ||
|
|
397493ca4d | ||
|
|
273f6213a2 | ||
|
|
2025418e16 | ||
|
|
0fd1898704 | ||
|
|
ffe93ed5a8 | ||
|
|
119ffbbf2f | ||
|
|
cd00dea6f1 | ||
|
|
15e4785de8 | ||
|
|
5dcaf5306a |
@@ -606,6 +606,12 @@ public class EndpointConfiguration {
|
||||
return endpointGroups.getOrDefault(group, new HashSet<>());
|
||||
}
|
||||
|
||||
public Set<String> getAllEndpoints() {
|
||||
return endpointGroups.values().stream()
|
||||
.flatMap(Set::stream)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isToolGroup(String group) {
|
||||
return "qpdf".equals(group)
|
||||
|| "OCRmyPDF".equals(group)
|
||||
|
||||
@@ -214,6 +214,7 @@ public class ApplicationProperties {
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
private String xFrameOptions = "DENY";
|
||||
private MFARequired mfaRequired = new MFARequired();
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -531,6 +532,12 @@ public class ApplicationProperties {
|
||||
private boolean hardFail = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MFARequired {
|
||||
private boolean enforceForAdmins = false;
|
||||
private boolean enforceForUsers = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+7
-7
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -11,9 +12,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
@@ -320,11 +318,13 @@ public class ConfigController {
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
|
||||
Collection<String> toCheck =
|
||||
(endpoints == null || endpoints.isEmpty())
|
||||
? endpointConfiguration.getAllEndpoints()
|
||||
: endpoints;
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
for (String endpoint : toCheck) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
|
||||
@@ -38,6 +38,7 @@ spring.devtools.livereload.enabled=true
|
||||
spring.devtools.restart.exclude=stirling.software.proprietary.security/**
|
||||
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
|
||||
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
|
||||
server.tomcat.max-http-header-size=32768
|
||||
|
||||
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
|
||||
@@ -85,6 +85,9 @@ security:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
||||
mfaRequired:
|
||||
enforceForAdmins: false # Set to 'true' to enforce MFA for admin users only - excludes normal users; saml2 and oauth2 users are not affected
|
||||
enforceForUsers: false # Set to 'true' to enforce MFA for users only - excludes admin users; saml2 and oauth2 users are not affected
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
|
||||
+3
@@ -316,6 +316,9 @@ public class ProprietaryUIDataController {
|
||||
if (settingsCopy.containsKey("mfaSecret")) {
|
||||
settingsCopy.put("mfaSecret", "********");
|
||||
}
|
||||
if (settingsCopy.containsKey("mfaRequired")) {
|
||||
settingsCopy.put("mfaRequired", settingsCopy.get("mfaRequired"));
|
||||
}
|
||||
userSettings.put(username, settingsCopy);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(username, lastRequest);
|
||||
|
||||
+12
-2
@@ -120,7 +120,12 @@ public class InitialSecuritySetup {
|
||||
.password(initialPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(false);
|
||||
.firstLogin(false)
|
||||
.requireMfa(
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getMfaRequired()
|
||||
.isEnforceForAdmins());
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Admin user created: {}", initialUsername);
|
||||
} else {
|
||||
@@ -140,7 +145,12 @@ public class InitialSecuritySetup {
|
||||
.password(defaultPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true);
|
||||
.firstLogin(true)
|
||||
.requireMfa(
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getMfaRequired()
|
||||
.isEnforceForAdmins());
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Default admin user created: {}", defaultUsername);
|
||||
}
|
||||
|
||||
+66
@@ -611,6 +611,72 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint to require MFA for a user
|
||||
*
|
||||
* @param username Username of the user to require MFA for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/mfa/require/admin/{username}")
|
||||
public ResponseEntity<?> requireMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
mfaService.setMfaRequired(user, true);
|
||||
return ResponseEntity.ok(Map.of("required", true));
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found for MFA enable: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to enable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint to set MFA as optional for a user
|
||||
*
|
||||
* @param username Username of the user to set MFA as optional for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/mfa/optional/admin/{username}")
|
||||
public ResponseEntity<?> optionalMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
mfaService.setMfaRequired(user, false);
|
||||
return ResponseEntity.ok(Map.of("required", false));
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found for MFA enable: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to enable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
|
||||
+16
-2
@@ -110,7 +110,12 @@ public class UserController {
|
||||
.username(username)
|
||||
.password(password)
|
||||
.team(team)
|
||||
.enabled(false);
|
||||
.enabled(false)
|
||||
.requireMfa(
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getMfaRequired()
|
||||
.isEnforceForUsers());
|
||||
User user = userService.saveUserCore(builder.build());
|
||||
|
||||
log.info("User registered successfully: {}", username);
|
||||
@@ -458,6 +463,10 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
forceMFA =
|
||||
applicationProperties.getSecurity().getMfaRequired().isEnforceForUsers()
|
||||
? true
|
||||
: forceMFA;
|
||||
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
|
||||
}
|
||||
userService.saveUserCore(builder.build());
|
||||
@@ -892,7 +901,12 @@ public class UserController {
|
||||
.password(temporaryPassword)
|
||||
.teamId(teamId)
|
||||
.role(role)
|
||||
.firstLogin(true);
|
||||
.firstLogin(true)
|
||||
.requireMfa(
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getMfaRequired()
|
||||
.isEnforceForUsers());
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Send invite email
|
||||
|
||||
+7
@@ -4,11 +4,14 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@@ -22,6 +25,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
|
||||
Optional<User> findByIdWithSettings(@Param("id") Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
|
||||
Optional<User> findByIdWithSettingsForUpdate(@Param("id") Long id);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
|
||||
+29
-13
@@ -66,14 +66,12 @@ public class MfaService {
|
||||
@Transactional
|
||||
public void setSecret(User user, String secret)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
// Clear existing values and flush the removals before inserting a new secret. This keeps
|
||||
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
|
||||
// same keys within a single transaction.
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
@@ -90,8 +88,9 @@ public class MfaService {
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "true");
|
||||
persist(managedUser);
|
||||
@@ -104,12 +103,17 @@ public class MfaService {
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
userRepository.flush();
|
||||
}
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
@@ -120,12 +124,16 @@ public class MfaService {
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
userRepository.flush();
|
||||
}
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
@@ -163,9 +171,10 @@ public class MfaService {
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public boolean markTotpStepUsed(User user, long timeStep)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed != null) {
|
||||
@@ -206,9 +215,10 @@ public class MfaService {
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void setMfaRequired(User user, boolean required)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
User managedUser = getUserWithSettings(user, true);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
|
||||
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
|
||||
@@ -225,10 +235,16 @@ public class MfaService {
|
||||
}
|
||||
|
||||
private User getUserWithSettings(User user) {
|
||||
return getUserWithSettings(user, false);
|
||||
}
|
||||
|
||||
private User getUserWithSettings(User user, boolean lockForUpdate) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return user;
|
||||
}
|
||||
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
return lockForUpdate
|
||||
? userRepository.findByIdWithSettingsForUpdate(user.getId()).orElse(user)
|
||||
: userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
}
|
||||
|
||||
private Map<String, String> ensureSettings(User user) {
|
||||
|
||||
+2
-2
@@ -393,12 +393,12 @@ public class UserService implements UserServiceInterface {
|
||||
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
|
||||
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
|
||||
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
|
||||
} else {
|
||||
} else if (settings.containsKey(MFA_SECRET_KEY)) {
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
}
|
||||
if (request.getMfaLastUsedStep() != null) {
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
|
||||
} else {
|
||||
} else if (settings.containsKey(MFA_LAST_USED_STEP_KEY)) {
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
}
|
||||
log.info(
|
||||
|
||||
+20
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -89,6 +90,25 @@ class AuthControllerMfaTest {
|
||||
verify(mfaService).setSecret(user, "SECRET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaAlwaysGeneratesNewSecret() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(totpService.generateSecret()).thenReturn("SECRET");
|
||||
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.secret").value("SECRET"))
|
||||
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
|
||||
|
||||
verify(mfaService).setSecret(user, "SECRET");
|
||||
verify(mfaService, never()).getSecret(any());
|
||||
verify(totpService).generateSecret();
|
||||
verify(totpService).buildOtpAuthUri(USERNAME, "SECRET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
|
||||
+27
-5
@@ -1,8 +1,12 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -23,14 +27,20 @@ class MfaServiceTest {
|
||||
@Test
|
||||
void setSecretStoresSecretAndDisablesMfa() throws Exception {
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
|
||||
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.setSecret(user, "NEWSECRET");
|
||||
|
||||
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
assertEquals("10", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(userRepository)
|
||||
.deleteSettingsByUserIdAndKeys(
|
||||
eq(1L),
|
||||
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@@ -48,15 +58,21 @@ class MfaServiceTest {
|
||||
@Test
|
||||
void disableMfaClearsSecretAndUsage() throws Exception {
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
|
||||
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
assertEquals("SECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertEquals("20", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(userRepository)
|
||||
.deleteSettingsByUserIdAndKeys(
|
||||
eq(1L),
|
||||
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@@ -122,15 +138,21 @@ class MfaServiceTest {
|
||||
@Test
|
||||
void clearPendingSecretResetsValues() throws Exception {
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
|
||||
when(userRepository.findByIdWithSettingsForUpdate(1L)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.clearPendingSecret(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
assertEquals("SECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertEquals("12", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(userRepository)
|
||||
.deleteSettingsByUserIdAndKeys(
|
||||
eq(1L),
|
||||
eq(List.of(MfaService.MFA_SECRET_KEY, MfaService.MFA_LAST_USED_STEP_KEY)));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.5.0'
|
||||
version = '2.5.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -1276,6 +1276,14 @@ saml2 = "SAML2 Only"
|
||||
description = "Time before failed login attempts are reset"
|
||||
label = "Login Reset Time (minutes)"
|
||||
|
||||
[admin.settings.security.mfaEnforcement.enforceForAdmins]
|
||||
description = "Require multi-factor authentication for all admin users"
|
||||
label = "Enforce Multi-Factor Authentication for Admins"
|
||||
|
||||
[admin.settings.security.mfaEnforcement.enforceForUsers]
|
||||
description = "Require multi-factor authentication for all non-admin users"
|
||||
label = "Enforce Multi-Factor Authentication for Users"
|
||||
|
||||
[admin.settings.security.ssoNotice]
|
||||
message = "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management."
|
||||
title = "Looking for SSO/SAML settings?"
|
||||
@@ -4273,6 +4281,7 @@ rotateRight = "Rotate Right"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
print = "Print PDF"
|
||||
ruler = "Ruler / Measure"
|
||||
draw = "Draw"
|
||||
redact = "Redact"
|
||||
exitRedaction = "Exit Redaction Mode"
|
||||
@@ -6694,6 +6703,7 @@ enable = "Enable"
|
||||
loading = "Loading people..."
|
||||
loginRequired = "Enable login mode first"
|
||||
member = "Member"
|
||||
mfaTitle = "MFA"
|
||||
noMembersFound = "No members found"
|
||||
role = "Role"
|
||||
searchMembers = "Search members..."
|
||||
@@ -6701,6 +6711,7 @@ status = "Status"
|
||||
team = "Team"
|
||||
title = "People"
|
||||
user = "User"
|
||||
viewProfile = "View your profile"
|
||||
|
||||
[workspace.people.actions]
|
||||
label = "Actions"
|
||||
@@ -6837,7 +6848,14 @@ users = "users"
|
||||
[workspace.people.mfa]
|
||||
adminDisableError = "Failed to disable MFA for user"
|
||||
adminDisableSuccess = "MFA disabled successfully for user"
|
||||
adminEnableError = "Failed to enable MFA for user"
|
||||
adminEnableSuccess = "MFA enabled successfully for user"
|
||||
disableByAdmin = "Disable MFA"
|
||||
enabled = "MFA Enabled"
|
||||
notRequired = "MFA Not Required"
|
||||
required = "MFA Required"
|
||||
setOptional = "Set MFA Optional"
|
||||
setRequired = "Set MFA Require"
|
||||
|
||||
[workspace.people.roleDescriptions]
|
||||
admin = "Can manage settings and invite members, with full administrative access."
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Stirling-PDF needs access to your local network to connect to self-hosted servers.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
@@ -76,7 +77,8 @@
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
"providerShortName": null,
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -52,12 +52,15 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const labelText = option.label;
|
||||
const comingSoonText = t('comingSoon', 'Coming soon');
|
||||
|
||||
const label = disabled ? (
|
||||
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
|
||||
<p>{option.label}</p>
|
||||
<Tooltip content={comingSoonText} position="left" arrow>
|
||||
<p>{labelText}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p>{option.label}</p>
|
||||
<p>{labelText}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -157,12 +160,27 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
compact = false,
|
||||
tooltip
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const { i18n, ready } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
|
||||
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 20);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Don't render until i18n is ready to prevent race condition
|
||||
// during SAML auth where components render before i18n initializes
|
||||
if (!ready || !i18n.language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the filtered list of supported languages from i18n
|
||||
// This respects server config (ui.languages) applied by AppConfigLoader
|
||||
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
|
||||
@@ -176,12 +194,6 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
label: name,
|
||||
}));
|
||||
|
||||
// Hide the language selector if there's only one language option
|
||||
// (no point showing a selector when there's nothing to select)
|
||||
if (languageOptions.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate dropdown width and grid columns based on number of languages
|
||||
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
|
||||
const dropdownWidth = languageOptions.length <= 4 ? 300
|
||||
@@ -225,16 +237,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
supportedLanguages['en-GB'] ||
|
||||
'English'; // Fallback if supportedLanguages lookup fails
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 20);
|
||||
}
|
||||
}, [opened]);
|
||||
// Hide the language selector if there's only one language option
|
||||
// (no point showing a selector when there's nothing to select)
|
||||
if (languageOptions.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -24,9 +24,8 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolIds = toolChain.map(tool => tool.toolId);
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ const CompareDocumentPane = ({
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
const renderedPageNumbers = useMemo(
|
||||
() => new Set(pages.map((p) => p.pageNumber)),
|
||||
[pages]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
@@ -88,7 +93,7 @@ const CompareDocumentPane = ({
|
||||
placeholder={dropdownPlaceholder ?? null}
|
||||
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||
onNavigate={onNavigateChange}
|
||||
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
|
||||
renderedPageNumbers={renderedPageNumbers}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
+10
-11
@@ -43,6 +43,16 @@ interface EditTableOfContentsWorkbenchViewProps {
|
||||
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const terminology = useFileActionTerminology();
|
||||
const files = data?.files ?? [];
|
||||
const thumbnails = data?.thumbnails ?? [];
|
||||
const previewFiles = useMemo(
|
||||
() =>
|
||||
files.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: thumbnails[index],
|
||||
})),
|
||||
[files, thumbnails]
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -63,8 +73,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
||||
bookmarks,
|
||||
selectedFileName,
|
||||
disabled,
|
||||
files,
|
||||
thumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
errorMessage,
|
||||
@@ -78,15 +86,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
||||
onFileClick,
|
||||
} = data;
|
||||
|
||||
const previewFiles = useMemo(
|
||||
() =>
|
||||
files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: thumbnails[index],
|
||||
})) ?? [],
|
||||
[files, thumbnails]
|
||||
);
|
||||
|
||||
const showResults = Boolean(
|
||||
previewFiles.length > 0 || downloadUrl || errorMessage
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileStatusIndicator from '@app/components/tools/shared/FileStatusIndicator';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
export interface FilesToolStepProps {
|
||||
selectedFiles: StirlingFile[];
|
||||
@@ -14,9 +14,7 @@ export function createFilesToolStep(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: FilesToolStepProps
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(t("files.title", "Files"), {
|
||||
return createStep(i18n.t("files.title", "Files"), {
|
||||
isVisible: true,
|
||||
isCollapsed: props.isCollapsed,
|
||||
onCollapsedClick: props.onCollapsedClick
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { saveOperationResults } from "@app/services/operationResultsSaveService";
|
||||
import { useFileActions, useFileState } from "@app/contexts/FileContext";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
export interface ReviewToolStepProps<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
@@ -151,10 +152,8 @@ export function createReviewToolStep<TParams = unknown>(
|
||||
) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(
|
||||
t("review", "Review"),
|
||||
i18n.t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
isCollapsed: props.isCollapsed,
|
||||
|
||||
@@ -80,8 +80,6 @@ const ToolStep = ({
|
||||
alwaysShowTooltip = false,
|
||||
tooltip
|
||||
}: ToolStepProps) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const parent = useContext(ToolStepContext);
|
||||
|
||||
// Auto-detect if we should show numbers based on sibling count or force option
|
||||
@@ -91,6 +89,8 @@ const ToolStep = ({
|
||||
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
|
||||
}, [showNumber, parent]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const stepNumber = _stepNumber;
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
|
||||
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
import { useFormFill } from '@app/tools/formFill/FormFillContext';
|
||||
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
|
||||
|
||||
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
|
||||
|
||||
// ─── Measure dictionary extraction ────────────────────────────────────────────
|
||||
|
||||
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
|
||||
try {
|
||||
const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString } = await import('@cantoo/pdf-lib');
|
||||
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true });
|
||||
|
||||
// Parse a Measure dict into a MeasureScale, or return null if malformed.
|
||||
const parseScale = (measureObj: unknown) => {
|
||||
if (!(measureObj instanceof PDFDict)) return null;
|
||||
const rObj = measureObj.lookup(PDFName.of('R'));
|
||||
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
|
||||
? rObj.decodeText() : '';
|
||||
// D = distance array, X = x-axis fallback
|
||||
let fmtArray = measureObj.lookup(PDFName.of('D'));
|
||||
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
|
||||
if (!(fmtArray instanceof PDFArray)) return null;
|
||||
const firstFmt = fmtArray.lookup(0);
|
||||
if (!(firstFmt instanceof PDFDict)) return null;
|
||||
const cObj = firstFmt.lookup(PDFName.of('C'));
|
||||
const uObj = firstFmt.lookup(PDFName.of('U'));
|
||||
if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null;
|
||||
const unit = (uObj instanceof PDFString || uObj instanceof PDFHexString)
|
||||
? uObj.decodeText() : 'units';
|
||||
return { factor: cObj.asNumber(), unit, ratioLabel };
|
||||
};
|
||||
|
||||
const result: PageMeasureScales = new Map();
|
||||
|
||||
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
|
||||
const page = pdfDoc.getPage(i);
|
||||
const pageHeight = page.getHeight();
|
||||
const pageNode = page.node as unknown as PDFDict;
|
||||
const viewports: ViewportScale[] = [];
|
||||
|
||||
// Spec-conformant: /VP array — each viewport can have its own scale and BBox
|
||||
const vpObj = pageNode.lookup(PDFName.of('VP'));
|
||||
if (vpObj instanceof PDFArray) {
|
||||
for (let j = 0; j < vpObj.size(); j++) {
|
||||
const vpEntry = vpObj.lookup(j);
|
||||
if (!(vpEntry instanceof PDFDict)) continue;
|
||||
const scale = parseScale(vpEntry.lookup(PDFName.of('Measure')));
|
||||
if (!scale) continue;
|
||||
let bbox: ViewportScale['bbox'] = null;
|
||||
const bboxObj = vpEntry.lookup(PDFName.of('BBox'));
|
||||
if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) {
|
||||
bbox = [
|
||||
(bboxObj.lookup(0) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(1) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(2) as PDFNumber).asNumber(),
|
||||
(bboxObj.lookup(3) as PDFNumber).asNumber(),
|
||||
];
|
||||
}
|
||||
viewports.push({ bbox, scale });
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: /Measure directly on page (non-conforming but seen in the wild)
|
||||
if (viewports.length === 0) {
|
||||
const scale = parseScale(pageNode.lookup(PDFName.of('Measure')));
|
||||
if (scale) viewports.push({ bbox: null, scale });
|
||||
}
|
||||
|
||||
if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo);
|
||||
}
|
||||
|
||||
return result.size > 0 ? result : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
@@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [applyChanges, setApplyChanges]);
|
||||
|
||||
// Ruler / measurement tool state
|
||||
const [isRulerActive, setIsRulerActive] = useState(false);
|
||||
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const file = effectiveFile?.file;
|
||||
if (!file) { setPageMeasureScales(null); return; }
|
||||
let cancelled = false;
|
||||
extractPageMeasureScales(file).then(scales => { if (!cancelled) setPageMeasureScales(scales); });
|
||||
return () => { cancelled = true; };
|
||||
}, [effectiveFile]);
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
|
||||
|
||||
// Auto-fetch form fields when a PDF is loaded in the viewer.
|
||||
// In normal viewer mode, this uses pdf-lib (frontend-only).
|
||||
@@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
<RulerOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isRulerActive}
|
||||
pageMeasureScales={pageMeasureScales}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -126,7 +126,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: true,
|
||||
withForms: !enableFormFill,
|
||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||
}),
|
||||
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A point anchored to a specific PDF page in PDF-unit space.
|
||||
* x and y are in PDF points (1/72 inch) relative to the page's top-left corner.
|
||||
*
|
||||
* This is the only truly zoom-invariant representation. Screen positions are
|
||||
* recovered at render time via getBoundingClientRect on the page element, so
|
||||
* scroll, zoom, and fixed page margins are all handled by the browser — we never
|
||||
* have to track them ourselves.
|
||||
*/
|
||||
interface PagePoint {
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
id: string;
|
||||
start: PagePoint;
|
||||
end: PagePoint;
|
||||
}
|
||||
|
||||
export interface RulerOverlayHandle {
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
interface RulerOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
pageMeasureScales?: PageMeasureScales | null;
|
||||
}
|
||||
|
||||
// ─── Math ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function dist(a: Point, b: Point): number {
|
||||
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
|
||||
}
|
||||
|
||||
function midpoint(a: Point, b: Point): Point {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
}
|
||||
|
||||
function perpUnit(a: Point, b: Point): { nx: number; ny: number } {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
return { nx: -dy / len, ny: dx / len };
|
||||
}
|
||||
|
||||
/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */
|
||||
function angleDeg(a: Point, b: Point): number {
|
||||
return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
function formatDist(pts: number): string {
|
||||
const mm = (pts / 72) * 25.4;
|
||||
if (mm < 100) return `${mm.toFixed(1)} mm`;
|
||||
if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`;
|
||||
return `${(mm / 1000).toFixed(2)} m`;
|
||||
}
|
||||
|
||||
function formatInches(pts: number): string {
|
||||
const inches = pts / 72;
|
||||
if (inches < 12) return `${inches.toFixed(2)} in`;
|
||||
return `${(inches / 12).toFixed(2)} ft`;
|
||||
}
|
||||
|
||||
export interface MeasureScale {
|
||||
/** real_world_value = pdf_points * factor */
|
||||
factor: number;
|
||||
/** e.g. "ft", "m" */
|
||||
unit: string;
|
||||
/** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */
|
||||
ratioLabel: string;
|
||||
}
|
||||
|
||||
export interface ViewportScale {
|
||||
/** BBox in PDF user space (bottom-left origin). null = entire page. */
|
||||
bbox: [number, number, number, number] | null;
|
||||
scale: MeasureScale;
|
||||
}
|
||||
|
||||
export interface PageScaleInfo {
|
||||
viewports: ViewportScale[];
|
||||
/** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
export type PageMeasureScales = Map<number, PageScaleInfo>;
|
||||
|
||||
/**
|
||||
* Given the start/end PagePoints of a measurement, find the scale from the
|
||||
* viewport whose BBox contains the midpoint. Falls back to the first viewport
|
||||
* if none contains it (handles whole-page viewports with bbox=null).
|
||||
*/
|
||||
function pickScale(
|
||||
start: PagePoint,
|
||||
end: PagePoint,
|
||||
pageMeasureScales: PageMeasureScales,
|
||||
): MeasureScale | null {
|
||||
if (start.pageIndex !== end.pageIndex) return null;
|
||||
const info = pageMeasureScales.get(start.pageIndex);
|
||||
if (!info?.viewports.length) return null;
|
||||
|
||||
// Midpoint in screen-space page coords (x left→right, y top→bottom, PDF points)
|
||||
const mx = (start.x + end.x) / 2;
|
||||
// Flip y: screen y=0 is page top; PDF user space y=0 is page bottom
|
||||
const my = info.pageHeight - (start.y + end.y) / 2;
|
||||
|
||||
for (const { bbox, scale } of info.viewports) {
|
||||
if (!bbox) return scale; // whole-page viewport
|
||||
const [x0, y0, x1, y1] = bbox;
|
||||
if (mx >= Math.min(x0, x1) && mx <= Math.max(x0, x1) &&
|
||||
my >= Math.min(y0, y1) && my <= Math.max(y0, y1)) {
|
||||
return scale;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatScaled(pts: number, scale: MeasureScale): string {
|
||||
const val = pts * scale.factor;
|
||||
if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`;
|
||||
if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`;
|
||||
if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`;
|
||||
return `${val.toFixed(3)} ${scale.unit}`;
|
||||
}
|
||||
|
||||
// Conversion factors to metres for known units
|
||||
const TO_METRES: Record<string, number> = {
|
||||
m: 1, cm: 0.01, mm: 0.001, km: 1000,
|
||||
ft: 0.3048, in: 0.0254, yd: 0.9144, mi: 1609.344,
|
||||
};
|
||||
|
||||
function isImperialUnit(unit: string): boolean {
|
||||
return ['ft', 'in', 'yd', 'mi'].includes(unit.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function formatMetricFromMetres(m: number): string {
|
||||
if (m >= 1000) return `${(m / 1000).toFixed(2)} km`;
|
||||
if (m >= 1) return `${m.toFixed(1)} m`;
|
||||
if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`;
|
||||
return `${(m * 1000).toFixed(1)} mm`;
|
||||
}
|
||||
|
||||
function formatImperialFromFeet(ft: number): string {
|
||||
if (ft >= 1) return `${ft.toFixed(2)} ft`;
|
||||
return `${(ft * 12).toFixed(2)} in`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scaled real-world value in the *other* unit system, or null if
|
||||
* the unit is not a recognised metric/imperial unit.
|
||||
* e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m"
|
||||
* 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx)
|
||||
*/
|
||||
function scaledCross(pts: number, scale: MeasureScale): string | null {
|
||||
const toM = TO_METRES[scale.unit.toLowerCase().trim()];
|
||||
if (!toM) return null;
|
||||
const metres = pts * scale.factor * toM;
|
||||
return isImperialUnit(scale.unit)
|
||||
? formatMetricFromMetres(metres)
|
||||
: formatImperialFromFeet(metres / 0.3048);
|
||||
}
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function findScrollEl(root: HTMLElement): HTMLElement | null {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
const el = node as HTMLElement;
|
||||
if (el === root) continue;
|
||||
const { overflow, overflowY, overflowX } = window.getComputedStyle(el);
|
||||
if ([overflow, overflowY, overflowX].some(v => v === 'auto' || v === 'scroll')) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOverPage(e: MouseEvent): boolean {
|
||||
return !!(e.target as Element).closest?.('[data-page-index]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest point on any page boundary and return it as both
|
||||
* an SVG screen coordinate and a PagePoint (page-relative PDF units).
|
||||
* Used to clamp the live line when the cursor drifts off the page.
|
||||
*/
|
||||
function nearestPageDocPt(
|
||||
cursor: Point,
|
||||
container: HTMLElement,
|
||||
zoom: number,
|
||||
): { screenPt: Point; docPt: PagePoint } | null {
|
||||
const pages = container.querySelectorAll('[data-page-index]');
|
||||
if (!pages.length) return null;
|
||||
|
||||
const cr = container.getBoundingClientRect();
|
||||
let bestDist = Infinity;
|
||||
let best: { screenPt: Point; docPt: PagePoint } | null = null;
|
||||
|
||||
pages.forEach(pageNode => {
|
||||
const pageEl = pageNode as HTMLElement;
|
||||
const r = pageEl.getBoundingClientRect();
|
||||
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
|
||||
|
||||
// Page bounds in SVG (container-relative) space
|
||||
const left = r.left - cr.left;
|
||||
const top = r.top - cr.top;
|
||||
const right = r.right - cr.left;
|
||||
const bottom = r.bottom - cr.top;
|
||||
|
||||
// Nearest point on this rect to the cursor (SVG space)
|
||||
const cx = Math.max(left, Math.min(right, cursor.x));
|
||||
const cy = Math.max(top, Math.min(bottom, cursor.y));
|
||||
const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2);
|
||||
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
// Convert SVG-space point (cx, cy) → page-relative viewport → PDF points:
|
||||
// viewport position of cx = cr.left + cx
|
||||
// page-relative position = (cr.left + cx) - r.left
|
||||
// PDF units = page-relative / zoom
|
||||
best = {
|
||||
screenPt: { x: cx, y: cy },
|
||||
docPt: {
|
||||
pageIndex,
|
||||
x: (cr.left + cx - r.left) / zoom,
|
||||
y: (cr.top + cy - r.top ) / zoom,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
const TICK = 10;
|
||||
const DOT_R = 5;
|
||||
const LH = 26; // label height (normal — 1 line)
|
||||
const LH2 = 44; // label height (hovered, no scale — 2 lines)
|
||||
const LH3 = 62; // label height (hovered, with scale — 3 lines)
|
||||
const LP = 10; // label horizontal padding
|
||||
const DEL_R = 8;
|
||||
|
||||
interface MeasurementLineProps {
|
||||
id: string;
|
||||
startS: Point;
|
||||
endS: Point;
|
||||
/** Physical distance in PDF points (= screen pixel distance / zoom). */
|
||||
distPts: number;
|
||||
hovered: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
onHover: (id: string | null) => void;
|
||||
measureScale?: MeasureScale | null;
|
||||
}
|
||||
|
||||
function MeasurementLine({ id, startS, endS, distPts, hovered, onDelete, onHover, measureScale }: MeasurementLineProps) {
|
||||
const mid = midpoint(startS, endS);
|
||||
const { nx, ny } = perpUnit(startS, endS);
|
||||
const ang = angleDeg(startS, endS);
|
||||
const angLabel = `∠ ${ang.toFixed(1)}°`;
|
||||
|
||||
// Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first)
|
||||
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
|
||||
|
||||
// Idle: scaled primary if scale present, else physical metric
|
||||
const distLabel = measureScale ? formatScaled(distPts, measureScale) : formatDist(distPts);
|
||||
|
||||
// Hover line 1 — both real-world values ordered by PDF unit system:
|
||||
// imperial PDF: "10.000 ft / 3.048 m"
|
||||
// metric PDF: "142.5 m / 467.5 ft"
|
||||
// no scale: "25.4 mm / 1.00 in" (metric first, default)
|
||||
const hoverLine1 = measureScale
|
||||
? (() => {
|
||||
const primary = formatScaled(distPts, measureScale);
|
||||
const cross = scaledCross(distPts, measureScale);
|
||||
return cross ? `${primary} / ${cross}` : primary;
|
||||
})()
|
||||
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
|
||||
|
||||
// Hover line 2 — both physical paper values, same order as line 1:
|
||||
// imperial PDF: "1.00 in / 25.4 mm"
|
||||
// metric PDF or no scale: "25.4 mm / 1.00 in"
|
||||
const hoverLine2 = measureScale
|
||||
? (imperialFirst
|
||||
? `${formatInches(distPts)} / ${formatDist(distPts)}`
|
||||
: `${formatDist(distPts)} / ${formatInches(distPts)}`)
|
||||
: null;
|
||||
|
||||
// Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle
|
||||
const contextLabel = measureScale?.ratioLabel
|
||||
? `${measureScale.ratioLabel} ${angLabel}`
|
||||
: angLabel;
|
||||
|
||||
const maxHoverLh = measureScale ? LH3 : LH2;
|
||||
const lh = hovered ? maxHoverLh : LH;
|
||||
|
||||
const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80);
|
||||
const lwHover = Math.max(
|
||||
hoverLine1.length * 8 + LP * 2,
|
||||
(hoverLine2?.length ?? 0) * 8 + LP * 2,
|
||||
contextLabel.length * 8 + LP * 2,
|
||||
80,
|
||||
);
|
||||
const lw = hovered ? lwHover : lwNormal;
|
||||
const sw = hovered ? 3 : 2;
|
||||
|
||||
const delX = mid.x + lwHover / 2 + DEL_R + 4;
|
||||
const delY = mid.y;
|
||||
|
||||
const hitLeft = mid.x - lwHover / 2 - 4;
|
||||
const hitTop = mid.y - maxHoverLh / 2 - 4;
|
||||
const hitWidth = (delX + DEL_R + 4) - hitLeft;
|
||||
const hitHeight = maxHoverLh + 8;
|
||||
|
||||
const mono = "'Roboto Mono','Consolas',monospace";
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={() => onHover(id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
<rect x={hitLeft} y={hitTop} width={hitWidth} height={hitHeight}
|
||||
fill="transparent" stroke="none" style={{ pointerEvents: 'all' }} />
|
||||
|
||||
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<line x1={endS.x + nx * TICK / 2} y1={endS.y + ny * TICK / 2}
|
||||
x2={endS.x - nx * TICK / 2} y2={endS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
|
||||
<circle cx={startS.x} cy={startS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
<circle cx={endS.x} cy={endS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
|
||||
<g style={{ pointerEvents: 'all', cursor: 'default' }}>
|
||||
<rect x={mid.x - lw / 2} y={mid.y - lh / 2} width={lw} height={lh}
|
||||
rx={5} fill="white" stroke="#1e88e5" strokeWidth={1.5} filter="url(#ruler-shadow)" />
|
||||
|
||||
{hovered && measureScale ? (
|
||||
// 3-line scaled hover
|
||||
<>
|
||||
<text x={mid.x} y={mid.y - 17} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||
<text x={mid.x} y={mid.y} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#546e7a" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{hoverLine2}</text>
|
||||
<text x={mid.x} y={mid.y + 17} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#5c6bc0" fontSize={10} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||
</>
|
||||
) : hovered ? (
|
||||
// 2-line no-scale hover
|
||||
<>
|
||||
<text x={mid.x} y={mid.y - 6} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{hoverLine1}</text>
|
||||
<text x={mid.x} y={mid.y + 13} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#5c6bc0" fontSize={11} fontFamily={mono} fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>{contextLabel}</text>
|
||||
</>
|
||||
) : (
|
||||
// Idle — single line
|
||||
<text x={mid.x} y={mid.y + 1} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>{distLabel}</text>
|
||||
)}
|
||||
|
||||
<g style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); onDelete(id); }}>
|
||||
<circle cx={delX} cy={delY} r={DEL_R} fill="#ef5350" stroke="white" strokeWidth={1.5} />
|
||||
<text x={delX} y={delY} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={12} fontWeight={700} style={{ userSelect: 'none' }}>×</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface LiveLineProps {
|
||||
startS: Point;
|
||||
endS: Point;
|
||||
zoom: number;
|
||||
measureScale?: MeasureScale | null;
|
||||
}
|
||||
|
||||
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
|
||||
const d = dist(startS, endS) / zoom; // PDF points from screen distance
|
||||
const mid = midpoint(startS, endS);
|
||||
const { nx, ny } = perpUnit(startS, endS);
|
||||
const ang = angleDeg(startS, endS);
|
||||
const distLabel = measureScale ? formatScaled(d, measureScale) : formatDist(d);
|
||||
const lw = Math.max(distLabel.length * 8 + LP * 2, 80);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
|
||||
stroke="#1e88e5" strokeWidth={2} strokeDasharray="7 4"
|
||||
strokeLinecap="round" opacity={0.85} />
|
||||
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
|
||||
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
|
||||
stroke="#1e88e5" strokeWidth={2} strokeLinecap="round" />
|
||||
{d > 4 && (
|
||||
<g>
|
||||
<rect x={mid.x - lw / 2} y={mid.y - LH2 / 2} width={lw} height={LH2}
|
||||
rx={5} fill="#1e88e5" stroke="white" strokeWidth={1} />
|
||||
<text x={mid.x} y={mid.y - 6}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={12}
|
||||
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={600}
|
||||
style={{ userSelect: 'none' }}>
|
||||
{distLabel}
|
||||
</text>
|
||||
<text x={mid.x} y={mid.y + 13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="rgba(255,255,255,0.85)" fontSize={11}
|
||||
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={500}
|
||||
style={{ userSelect: 'none' }}>
|
||||
{`∠ ${ang.toFixed(1)}°`}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const RulerOverlay = React.forwardRef<RulerOverlayHandle, RulerOverlayProps>(
|
||||
({ containerRef, isActive, pageMeasureScales }, ref) => {
|
||||
const [measurements, setMeasurements] = useState<Measurement[]>([]);
|
||||
const [firstPt, setFirstPt] = useState<PagePoint | null>(null);
|
||||
/** Current cursor in SVG screen-space — for live crosshair and live line rendering. */
|
||||
const [cursorS, setCursorS] = useState<Point | null>(null);
|
||||
/** Current cursor in page-relative PDF units — for finalising off-page clicks. */
|
||||
const [cursorDoc, setCursorDoc] = useState<PagePoint | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Incremented on scroll to trigger re-renders.
|
||||
* We no longer store the scroll value — getBoundingClientRect handles that
|
||||
* automatically and is always accurate regardless of scroll position.
|
||||
*/
|
||||
const [, setScrollVersion] = useState(0);
|
||||
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
const scrollCleanupRef = useRef<(() => void) | null>(null);
|
||||
const idCounter = useRef(0);
|
||||
|
||||
const firstPtRef = useRef<PagePoint | null>(null);
|
||||
useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]);
|
||||
|
||||
const cursorDocRef = useRef<PagePoint | null>(null);
|
||||
|
||||
// ── Zoom ──────────────────────────────────────────────────────────────────
|
||||
const viewer = useViewer();
|
||||
const { registerImmediateZoomUpdate } = viewer;
|
||||
|
||||
const [zoom, setZoom] = useState<number>(() => {
|
||||
try { return ((viewer.getZoomState() as any)?.zoomPercent ?? 140) / 100; }
|
||||
catch { return 1.4; }
|
||||
});
|
||||
|
||||
const zoomRef = useRef(zoom);
|
||||
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerImmediateZoomUpdate((pct) => {
|
||||
const newZoom = pct / 100;
|
||||
zoomRef.current = newZoom; // immediate for event-listener closures
|
||||
setZoom(newZoom); // re-render #1: zoom updated, but PDF.js DOM may not be yet
|
||||
// re-render #2: after PDF.js has updated page element dimensions in the DOM,
|
||||
// so getBoundingClientRect returns the correct positions for the new zoom level.
|
||||
requestAnimationFrame(() => setScrollVersion(n => n + 1));
|
||||
});
|
||||
}, [registerImmediateZoomUpdate]);
|
||||
|
||||
// ── Scroll tracking ────────────────────────────────────────────────────────
|
||||
// We only need re-renders on scroll; getBoundingClientRect gives us accurate
|
||||
// positions without needing to know the scroll offset ourselves.
|
||||
|
||||
const attachScrollEl = useCallback((el: HTMLElement) => {
|
||||
scrollCleanupRef.current?.();
|
||||
scrollElRef.current = el;
|
||||
const handler = () => setScrollVersion(n => n + 1);
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
scrollCleanupRef.current = () => el.removeEventListener('scroll', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const tryAttach = () => {
|
||||
const el = findScrollEl(container);
|
||||
if (el) { attachScrollEl(el); return true; }
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!tryAttach()) {
|
||||
const timer = setTimeout(() => tryAttach(), 600);
|
||||
return () => { clearTimeout(timer); scrollCleanupRef.current?.(); };
|
||||
}
|
||||
return () => scrollCleanupRef.current?.();
|
||||
}, [containerRef, attachScrollEl]);
|
||||
|
||||
// Re-find scroll element when zoom changes (PDF.js may recreate the scroll DOM).
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const el = findScrollEl(container);
|
||||
if (el && el !== scrollElRef.current) attachScrollEl(el);
|
||||
}, [zoom, containerRef, attachScrollEl]);
|
||||
|
||||
// ── Imperative handle ──────────────────────────────────────────────────────
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
clearAll: () => { setMeasurements([]); setFirstPt(null); setCursorS(null); setCursorDoc(null); },
|
||||
}));
|
||||
|
||||
// ── Reset when deactivated ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isActive) { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
|
||||
}, [isActive]);
|
||||
|
||||
// ── Mouse events ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!isActive || !el) return;
|
||||
|
||||
const toScreenPt = (e: MouseEvent): Point => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a mouse event to a page-relative PagePoint.
|
||||
* Returns null if the cursor is not directly over a page element.
|
||||
*/
|
||||
const toDocPagePt = (e: MouseEvent): PagePoint | null => {
|
||||
const pageEl = (e.target as Element).closest?.('[data-page-index]') as HTMLElement | null;
|
||||
if (!pageEl) return null;
|
||||
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
|
||||
const r = pageEl.getBoundingClientRect();
|
||||
const z = zoomRef.current;
|
||||
return { pageIndex, x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z };
|
||||
};
|
||||
|
||||
const clearCursor = () => {
|
||||
setCursorS(null);
|
||||
setCursorDoc(null);
|
||||
cursorDocRef.current = null;
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const screenPt = toScreenPt(e);
|
||||
|
||||
if (isOverPage(e)) {
|
||||
el.style.cursor = 'crosshair';
|
||||
const docPt = toDocPagePt(e);
|
||||
setCursorS(screenPt);
|
||||
setCursorDoc(docPt);
|
||||
cursorDocRef.current = docPt;
|
||||
} else if (firstPtRef.current !== null) {
|
||||
// First point placed, cursor wandered off page — clamp to nearest edge
|
||||
el.style.cursor = 'crosshair';
|
||||
const result = nearestPageDocPt(screenPt, el, zoomRef.current);
|
||||
if (result) {
|
||||
setCursorS(result.screenPt);
|
||||
setCursorDoc(result.docPt);
|
||||
cursorDocRef.current = result.docPt;
|
||||
}
|
||||
} else {
|
||||
el.style.cursor = 'default';
|
||||
clearCursor();
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as Element).closest?.('[data-ruler-interactive]')) return;
|
||||
|
||||
const overPage = isOverPage(e);
|
||||
if (!overPage && firstPtRef.current === null) return;
|
||||
e.preventDefault();
|
||||
|
||||
const dp = overPage ? toDocPagePt(e) : cursorDocRef.current;
|
||||
if (!dp) return;
|
||||
|
||||
setFirstPt(prev => {
|
||||
if (!prev) { firstPtRef.current = dp; return dp; }
|
||||
firstPtRef.current = null;
|
||||
const id = `ruler-${++idCounter.current}`;
|
||||
setMeasurements(m => [...m, { id, start: prev, end: dp }]);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
el.style.cursor = '';
|
||||
if (firstPtRef.current === null) clearCursor();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
|
||||
};
|
||||
|
||||
el.addEventListener('mousemove', onMove);
|
||||
el.addEventListener('click', onClick);
|
||||
el.addEventListener('mouseleave', onLeave);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
el.removeEventListener('mousemove', onMove);
|
||||
el.removeEventListener('click', onClick);
|
||||
el.removeEventListener('mouseleave', onLeave);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
el.style.cursor = '';
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const deleteMeasurement = useCallback((id: string) => {
|
||||
setMeasurements(prev => prev.filter(m => m.id !== id));
|
||||
}, []);
|
||||
|
||||
if (!isActive && measurements.length === 0) return null;
|
||||
|
||||
// ── PagePoint → SVG screen coordinates ────────────────────────────────────
|
||||
/**
|
||||
* Convert a page-anchored point to SVG screen coordinates.
|
||||
*
|
||||
* Uses getBoundingClientRect so the browser computes the exact screen position
|
||||
* accounting for scroll, zoom, page margins, centering — everything. This is
|
||||
* why we no longer need to track scroll offsets.
|
||||
*
|
||||
* Returns null if the page element isn't in the DOM (shouldn't happen with
|
||||
* PDF.js placeholder divs, but guard anyway).
|
||||
*/
|
||||
const pagePointToScreen = (pt: PagePoint): Point | null => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return null;
|
||||
const pageEl = container.querySelector(`[data-page-index="${pt.pageIndex}"]`) as HTMLElement | null;
|
||||
if (!pageEl) return null;
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
return {
|
||||
x: pageRect.left - containerRect.left + pt.x * zoom,
|
||||
y: pageRect.top - containerRect.top + pt.y * zoom,
|
||||
};
|
||||
};
|
||||
|
||||
const firstPtS = firstPt ? pagePointToScreen(firstPt) : null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<filter id="ruler-shadow" x="-20%" y="-50%" width="140%" height="200%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(0,0,0,0.22)" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Completed measurements */}
|
||||
{measurements.map(m => {
|
||||
const startS = pagePointToScreen(m.start);
|
||||
const endS = pagePointToScreen(m.end);
|
||||
if (!startS || !endS) return null;
|
||||
const mScale = pageMeasureScales ? pickScale(m.start, m.end, pageMeasureScales) : null;
|
||||
return (
|
||||
<MeasurementLine
|
||||
key={m.id}
|
||||
id={m.id}
|
||||
startS={startS}
|
||||
endS={endS}
|
||||
distPts={dist(startS, endS) / zoom}
|
||||
hovered={hoveredId === m.id}
|
||||
onDelete={deleteMeasurement}
|
||||
onHover={setHoveredId}
|
||||
measureScale={mScale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Live line while drawing */}
|
||||
{isActive && firstPtS && cursorS && (
|
||||
<LiveLine
|
||||
startS={firstPtS} endS={cursorS} zoom={zoom}
|
||||
measureScale={pageMeasureScales && firstPt && cursorDoc
|
||||
? pickScale(firstPt, cursorDoc, pageMeasureScales)
|
||||
: null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* First-point anchor dot */}
|
||||
{isActive && firstPtS && (
|
||||
<circle cx={firstPtS.x} cy={firstPtS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
|
||||
)}
|
||||
|
||||
{/* Crosshair */}
|
||||
{isActive && cursorS && (
|
||||
<g opacity={0.75}>
|
||||
<line x1={cursorS.x - 12} y1={cursorS.y} x2={cursorS.x + 12} y2={cursorS.y} stroke="#1e88e5" strokeWidth={1.5} />
|
||||
<line x1={cursorS.x} y1={cursorS.y - 12} x2={cursorS.x} y2={cursorS.y + 12} stroke="#1e88e5" strokeWidth={1.5} />
|
||||
<circle cx={cursorS.x} cy={cursorS.y} r={2} fill="#1e88e5" />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Clear all */}
|
||||
{measurements.length > 0 && (
|
||||
<g data-ruler-interactive="true" style={{ pointerEvents: 'all', cursor: 'pointer' }}
|
||||
onClick={(e) => { e.stopPropagation(); setMeasurements([]); }}>
|
||||
<rect x={8} y={8} width={88} height={26} rx={5}
|
||||
fill="rgba(239,83,80,0.9)" stroke="white" strokeWidth={1} />
|
||||
<text x={52} y={25} textAnchor="middle" fill="white" fontSize={12}
|
||||
fontFamily="sans-serif" fontWeight={600} style={{ userSelect: 'none' }}>
|
||||
Clear all
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
RulerOverlay.displayName = 'RulerOverlay';
|
||||
@@ -14,8 +14,12 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation
|
||||
import { BASE_PATH, withBasePath } from '@app/constants/app';
|
||||
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import StraightenIcon from '@mui/icons-material/Straighten';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
export function useViewerRightRailButtons(
|
||||
isRulerActive?: boolean,
|
||||
setIsRulerActive?: (v: boolean) => void,
|
||||
) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
|
||||
@@ -82,6 +86,8 @@ export function useViewerRightRailButtons() {
|
||||
|
||||
const isFormFillActive = (selectedTool as string) === 'formFill';
|
||||
|
||||
const rulerLabel = t('rightRail.ruler', 'Ruler / Measure');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
const buttons: RightRailButtonWithAction[] = [
|
||||
{
|
||||
@@ -137,6 +143,24 @@ export function useViewerRightRailButtons() {
|
||||
setIsPanning(prev => !prev);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewer-ruler',
|
||||
icon: <StraightenIcon sx={{ fontSize: '1.5rem' }} />,
|
||||
tooltip: rulerLabel,
|
||||
ariaLabel: rulerLabel,
|
||||
section: 'top' as const,
|
||||
order: 25,
|
||||
active: Boolean(isRulerActive),
|
||||
onClick: () => {
|
||||
const next = !isRulerActive;
|
||||
setIsRulerActive?.(next);
|
||||
// Disable pan when activating ruler — they conflict
|
||||
if (next && viewer.getPanState()?.isPanning) {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
@@ -317,6 +341,9 @@ export function useViewerRightRailButtons() {
|
||||
redactionActiveType,
|
||||
formFillLabel,
|
||||
isFormFillActive,
|
||||
rulerLabel,
|
||||
isRulerActive,
|
||||
setIsRulerActive,
|
||||
]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
// Track whether we've done the global fetch to prevent duplicate requests
|
||||
let globalFetchDone = false;
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
/**
|
||||
@@ -72,17 +72,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAllEndpointStatuses = async (force = false) => {
|
||||
const endpointsKey = [...endpoints].sort().join(',');
|
||||
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
// Skip if already fetched globally and not forced
|
||||
if (!force && globalFetchDone) {
|
||||
console.debug('[useEndpointConfig] Using global cache');
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
@@ -93,6 +93,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
@@ -103,45 +104,21 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug('[useEndpointConfig] Fetching endpoint statuses', { count: endpoints.length, force });
|
||||
console.debug('[useEndpointConfig] Fetching all endpoint statuses from server');
|
||||
|
||||
// Check which endpoints we haven't fetched yet
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Fetch all endpoints at once - no query params needed
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`);
|
||||
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
// Populate global cache with all results
|
||||
Object.entries(response.data).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
globalFetchDone = true;
|
||||
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
// Return status for the requested endpoints
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
@@ -158,17 +135,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
endpoints.forEach(endpoint => {
|
||||
globalEndpointCache[endpoint] = { enabled: true, reason: null };
|
||||
});
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
acc.details[endpoint] = { enabled: true, reason: null };
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
@@ -181,14 +158,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
console.error('[EndpointConfig] Failed to check endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
acc.details[endpoint] = { enabled: true, reason: null };
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
@@ -208,8 +184,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
useEffect(() => {
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
|
||||
// Clear the global cache to allow refetch with JWT
|
||||
globalFetchedSets.clear();
|
||||
globalFetchDone = false;
|
||||
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
|
||||
fetchAllEndpointStatuses(true);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.5.0',
|
||||
appVersion: '2.5.1',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -213,12 +213,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const type = params.get('type') || parsed.searchParams.get('type');
|
||||
const accessTokenFromHash = params.get('access_token');
|
||||
const accessTokenFromQuery = parsed.searchParams.get('access_token');
|
||||
const serverFromQuery = parsed.searchParams.get('server');
|
||||
|
||||
// Handle self-hosted SSO deep link
|
||||
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
|
||||
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
|
||||
if (type === 'sso' || type === 'sso-selfhosted') {
|
||||
if (authService.isSelfHostedDeepLinkFlowActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessTokenFromHash = params.get('access_token');
|
||||
const accessTokenFromQuery = parsed.searchParams.get('access_token');
|
||||
const serverFromQuery = parsed.searchParams.get('server');
|
||||
const token = accessTokenFromHash || accessTokenFromQuery;
|
||||
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
|
||||
if (!token || !serverUrl) {
|
||||
|
||||
@@ -190,10 +190,8 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
|
||||
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
|
||||
`/api/v1/config/endpoints-availability`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export class AuthService {
|
||||
private lastTokenSaveTime: number = 0;
|
||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||
private refreshPromise: Promise<boolean> | null = null;
|
||||
private selfHostedDeepLinkFlowActive = false;
|
||||
|
||||
static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
@@ -176,6 +177,10 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
isSelfHostedDeepLinkFlowActive(): boolean {
|
||||
return this.selfHostedDeepLinkFlowActive;
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
|
||||
}
|
||||
@@ -782,22 +787,27 @@ export class AuthService {
|
||||
// ignore URL parsing failures
|
||||
}
|
||||
|
||||
// Open in system browser and wait for deep link callback
|
||||
if (await this.openInSystemBrowser(authUrl)) {
|
||||
return this.waitForDeepLinkCompletion(trimmedServer);
|
||||
}
|
||||
|
||||
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
|
||||
// Register deep-link listener before opening browser to avoid callback races on first launch.
|
||||
return this.waitForDeepLinkCompletion(trimmedServer, async () => {
|
||||
if (!(await this.openInSystemBrowser(authUrl))) {
|
||||
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a deep-link event to complete self-hosted SSO after system browser OAuth
|
||||
*/
|
||||
private async waitForDeepLinkCompletion(serverUrl: string): Promise<UserInfo> {
|
||||
private async waitForDeepLinkCompletion(
|
||||
serverUrl: string,
|
||||
startFlow?: () => Promise<void>
|
||||
): Promise<UserInfo> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Deep link authentication is only supported in Tauri desktop app.');
|
||||
}
|
||||
|
||||
this.selfHostedDeepLinkFlowActive = true;
|
||||
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
let completed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
@@ -807,6 +817,7 @@ export class AuthService {
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(new Error('SSO login timed out. Please try again.'));
|
||||
}
|
||||
}, 120_000);
|
||||
@@ -825,6 +836,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(new Error(error || 'Authentication was not successful.'));
|
||||
return;
|
||||
}
|
||||
@@ -845,6 +857,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack');
|
||||
reject(new Error('Invalid authentication state. Nonce validation failed.'));
|
||||
return;
|
||||
@@ -854,6 +867,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
console.log('[Desktop AuthService] Nonce validated successfully');
|
||||
|
||||
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
|
||||
@@ -870,10 +884,39 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to complete SSO'));
|
||||
}
|
||||
}).then((fn) => {
|
||||
}).then(async (fn) => {
|
||||
unlisten = fn;
|
||||
|
||||
if (!startFlow || completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startFlow();
|
||||
} catch (err) {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to start SSO login'));
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to listen for deep link events'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+45
@@ -17,6 +17,10 @@ interface SecuritySettingsData {
|
||||
loginAttemptCount?: number;
|
||||
loginResetTimeMinutes?: number;
|
||||
xFrameOptions?: string;
|
||||
mfaRequired?: {
|
||||
enforceForAdmins?: boolean;
|
||||
enforceForUsers?: boolean;
|
||||
}
|
||||
jwt?: {
|
||||
persistence?: boolean;
|
||||
enableKeyRotation?: boolean;
|
||||
@@ -130,6 +134,9 @@ export default function AdminSecuritySection() {
|
||||
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
||||
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
||||
'security.xFrameOptions': securitySettings.xFrameOptions,
|
||||
// MFA settings
|
||||
'security.mfaRequired.enforceForAdmins': securitySettings.mfaRequired?.enforceForAdmins,
|
||||
'security.mfaRequired.enforceForUsers': securitySettings.mfaRequired?.enforceForUsers,
|
||||
// JWT settings
|
||||
'security.jwt.persistence': securitySettings.jwt?.persistence,
|
||||
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
|
||||
@@ -314,6 +321,44 @@ export default function AdminSecuritySection() {
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MFA Enforcement Settings */}
|
||||
<div>
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.mfaEnforcement.enforceForAdmins.label', 'Enforce Multi-Factor Authentication for Admins')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.mfaEnforcement.enforceForAdmins.description', 'Require multi-factor authentication for all admin users')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mfaRequired?.enforceForAdmins || false}
|
||||
onChange={(e) => setSettings({ ...settings, mfaRequired: { ...settings?.mfaRequired, enforceForAdmins: e.target.checked } })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mfaRequired.enforceForAdmins')} />
|
||||
</Group>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">{t('admin.settings.security.mfaEnforcement.enforceForUsers.label', 'Enforce Multi-Factor Authentication for Users')}</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('admin.settings.security.mfaEnforcement.enforceForUsers.description', 'Require multi-factor authentication for all non-admin users')}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mfaRequired?.enforceForUsers || false}
|
||||
onChange={(e) => setSettings({ ...settings, mfaRequired: { ...settings?.mfaRequired, enforceForUsers: e.target.checked } })}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending('mfaRequired.enforceForUsers')} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
+352
-229
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
@@ -17,21 +17,21 @@ import {
|
||||
CloseButton,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { userManagementService, User } from '@app/services/userManagementService';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
|
||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { userManagementService, User } from "@app/services/userManagementService";
|
||||
import { teamService, Team } from "@app/services/teamService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import InviteMembersModal from "@app/components/shared/InviteMembersModal";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
export default function PeopleSection() {
|
||||
const { t } = useTranslation();
|
||||
@@ -43,7 +43,7 @@ export default function PeopleSection() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [inviteModalOpened, setInviteModalOpened] = useState(false);
|
||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
|
||||
@@ -67,52 +67,82 @@ export default function PeopleSection() {
|
||||
return;
|
||||
}
|
||||
if (hasNoSlots) {
|
||||
navigate('/settings/adminPlan');
|
||||
navigate("/settings/adminPlan");
|
||||
return;
|
||||
}
|
||||
setInviteModalOpened(true);
|
||||
};
|
||||
|
||||
const addMemberTooltip = !loginEnabled
|
||||
? t('workspace.people.loginRequired', 'Enable login mode first')
|
||||
? t("workspace.people.loginRequired", "Enable login mode first")
|
||||
: hasNoSlots
|
||||
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
|
||||
? t("workspace.people.license.noSlotsAvailable", "No user slots available")
|
||||
: null;
|
||||
|
||||
const isCurrentUser = (user: User) => currentUser?.username === user.username;
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
role: 'ROLE_USER',
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
(async () => {
|
||||
await fetchData();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
|
||||
console.log("[PeopleSection] Email invites enabled:", config.enableEmailInvites);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSetMfaButton = async (user: User) => {
|
||||
try {
|
||||
if (!user.mfaRequired) {
|
||||
await userManagementService.requireMfaByAdmin(user.username, true);
|
||||
console.log("[PeopleSection] set require maf to mandatory");
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.people.mfa.adminEnableSuccess", "MFA enabled successfully for user"),
|
||||
});
|
||||
} else {
|
||||
await userManagementService.requireMfaByAdmin(user.username, false);
|
||||
console.log("[PeopleSection] set require maf to optional");
|
||||
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.people.mfa.adminDisableSuccess", "MFA disabled successfully for user"),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[PeopleSection] Failed to enable MFA for user:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("workspace.people.mfa.adminEnableError", "Failed to enable MFA for user");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (loginEnabled) {
|
||||
const [adminData, teamsData] = await Promise.all([
|
||||
userManagementService.getUsers(),
|
||||
teamService.getTeams(),
|
||||
]);
|
||||
const [adminData, teamsData] = await Promise.all([userManagementService.getUsers(), teamService.getTeams()]);
|
||||
|
||||
// Enrich users with session data
|
||||
const enrichedUsers = adminData.users.map(user => ({
|
||||
const enrichedUsers = adminData.users.map((user) => ({
|
||||
...user,
|
||||
isActive: adminData.userSessions[user.username] || false,
|
||||
lastRequest: adminData.userLastRequest[user.username] || undefined,
|
||||
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === 'true',
|
||||
mfaEnabled: adminData.userSettings?.[user.username]?.mfaEnabled === "true",
|
||||
mfaRequired: adminData.userSettings?.[user.username]?.mfaRequired === "true",
|
||||
}));
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
@@ -133,57 +163,61 @@ export default function PeopleSection() {
|
||||
const exampleUsers: User[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
enabled: true,
|
||||
roleName: 'ROLE_ADMIN',
|
||||
rolesAsString: 'ROLE_ADMIN',
|
||||
authenticationType: 'password',
|
||||
roleName: "ROLE_ADMIN",
|
||||
rolesAsString: "ROLE_ADMIN",
|
||||
authenticationType: "password",
|
||||
isActive: true,
|
||||
lastRequest: Date.now(),
|
||||
team: { id: 1, name: 'Engineering' }
|
||||
team: { id: 1, name: "Engineering" },
|
||||
mfaRequired: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'john.doe',
|
||||
email: 'john.doe@example.com',
|
||||
username: "john.doe",
|
||||
email: "john.doe@example.com",
|
||||
enabled: true,
|
||||
roleName: 'ROLE_USER',
|
||||
rolesAsString: 'ROLE_USER',
|
||||
authenticationType: 'password',
|
||||
roleName: "ROLE_USER",
|
||||
rolesAsString: "ROLE_USER",
|
||||
authenticationType: "password",
|
||||
isActive: false,
|
||||
lastRequest: Date.now() - 86400000,
|
||||
team: { id: 1, name: 'Engineering' }
|
||||
team: { id: 1, name: "Engineering" },
|
||||
mfaRequired: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: 'jane.smith',
|
||||
email: 'jane.smith@example.com',
|
||||
username: "jane.smith",
|
||||
email: "jane.smith@example.com",
|
||||
enabled: true,
|
||||
roleName: 'ROLE_USER',
|
||||
rolesAsString: 'ROLE_USER',
|
||||
authenticationType: 'oauth',
|
||||
roleName: "ROLE_USER",
|
||||
rolesAsString: "ROLE_USER",
|
||||
authenticationType: "oauth",
|
||||
isActive: true,
|
||||
lastRequest: Date.now(),
|
||||
team: { id: 2, name: 'Marketing' }
|
||||
team: { id: 2, name: "Marketing" },
|
||||
mfaRequired: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: 'bob.wilson',
|
||||
email: 'bob.wilson@example.com',
|
||||
username: "bob.wilson",
|
||||
email: "bob.wilson@example.com",
|
||||
enabled: false,
|
||||
roleName: 'ROLE_USER',
|
||||
rolesAsString: 'ROLE_USER',
|
||||
authenticationType: 'password',
|
||||
roleName: "ROLE_USER",
|
||||
rolesAsString: "ROLE_USER",
|
||||
authenticationType: "password",
|
||||
isActive: false,
|
||||
lastRequest: Date.now() - 604800000,
|
||||
team: undefined
|
||||
}
|
||||
team: undefined,
|
||||
mfaRequired: false,
|
||||
},
|
||||
];
|
||||
|
||||
const exampleTeams: Team[] = [
|
||||
{ id: 1, name: 'Engineering', userCount: 3 },
|
||||
{ id: 2, name: 'Marketing', userCount: 2 }
|
||||
{ id: 1, name: "Engineering", userCount: 3 },
|
||||
{ id: 2, name: "Marketing", userCount: 2 },
|
||||
];
|
||||
|
||||
setUsers(exampleUsers);
|
||||
@@ -201,8 +235,8 @@ export default function PeopleSection() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PeopleSection] Failed to fetch people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
console.error("[PeopleSection] Failed to fetch people data:", error);
|
||||
alert({ alertType: "error", title: "Failed to load people data" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -218,16 +252,17 @@ export default function PeopleSection() {
|
||||
role: editForm.role,
|
||||
teamId: editForm.teamId,
|
||||
});
|
||||
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
|
||||
alert({ alertType: "success", title: t("workspace.people.editMember.success") });
|
||||
closeEditModal();
|
||||
fetchData();
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to update user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.editMember.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
console.error("[PeopleSection] Failed to update user:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("workspace.people.editMember.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -236,35 +271,61 @@ export default function PeopleSection() {
|
||||
const handleToggleEnabled = async (user: User) => {
|
||||
try {
|
||||
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
|
||||
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
|
||||
fetchData();
|
||||
alert({ alertType: "success", title: t("workspace.people.toggleEnabled.success") });
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to toggle user status:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.toggleEnabled.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
console.error("[PeopleSection] Failed to toggle user status:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("workspace.people.toggleEnabled.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
|
||||
const confirmMessage = t(
|
||||
"workspace.people.confirmDelete",
|
||||
"Are you sure you want to delete this user? This action cannot be undone.",
|
||||
);
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagementService.deleteUser(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
|
||||
fetchData();
|
||||
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to delete user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.deleteUserError', 'Failed to delete user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
console.error("[PeopleSection] Failed to delete user:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("workspace.people.deleteUserError", "Failed to delete user");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableMfaByAdmin = async (user: User) => {
|
||||
try {
|
||||
if (user.mfaRequired) {
|
||||
await userManagementService.requireMfaByAdmin(user.username, false);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.people.mfa.adminDisableSuccess", "MFA disabled successfully for user"),
|
||||
});
|
||||
}
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
console.error("[PeopleSection] Failed to disable MFA for user:", error);
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t("workspace.people.mfa.adminDisableError", "Failed to disable MFA for user");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -291,27 +352,31 @@ export default function PeopleSection() {
|
||||
setEditUserModalOpened(false);
|
||||
setSelectedUser(null);
|
||||
setEditForm({
|
||||
role: 'ROLE_USER',
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const filteredUsers = users.filter((user) => user.username.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: 'ROLE_ADMIN',
|
||||
label: t('workspace.people.admin'),
|
||||
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
|
||||
icon: 'admin-panel-settings'
|
||||
value: "ROLE_ADMIN",
|
||||
label: t("workspace.people.admin"),
|
||||
description: t(
|
||||
"workspace.people.roleDescriptions.admin",
|
||||
"Can manage settings and invite members, with full administrative access.",
|
||||
),
|
||||
icon: "admin-panel-settings",
|
||||
},
|
||||
{
|
||||
value: 'ROLE_USER',
|
||||
label: t('workspace.people.member'),
|
||||
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
|
||||
icon: 'person'
|
||||
value: "ROLE_USER",
|
||||
label: t("workspace.people.member"),
|
||||
description: t(
|
||||
"workspace.people.roleDescriptions.member",
|
||||
"Can view and edit shared files, but cannot manage workspace settings or members.",
|
||||
),
|
||||
icon: "person",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -319,8 +384,10 @@ export default function PeopleSection() {
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>{option.label}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "normal", lineHeight: 1.4 }}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -337,7 +404,7 @@ export default function PeopleSection() {
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.people.loading', 'Loading people...')}
|
||||
{t("workspace.people.loading", "Loading people...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
@@ -348,34 +415,40 @@ export default function PeopleSection() {
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('workspace.people.title')}
|
||||
{t("workspace.people.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.people.description')}
|
||||
{t("workspace.people.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* License Information - Compact */}
|
||||
{licenseInfo && (
|
||||
<Group gap="md" style={{ fontSize: '0.875rem' }}>
|
||||
<Group gap="md" style={{ fontSize: "0.875rem" }}>
|
||||
<Text size="sm" span c="dimmed">
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
|
||||
<Text component="span" c="dimmed"> / </Text>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
|
||||
<Text component="span" c="dimmed"> {t('workspace.people.license.users', 'users')}</Text>
|
||||
<Text component="span" fw={600} c="inherit">
|
||||
{licenseInfo.totalUsers}
|
||||
</Text>
|
||||
<Text component="span" c="dimmed">
|
||||
{" "}
|
||||
/{" "}
|
||||
</Text>
|
||||
<Text component="span" fw={600} c="inherit">
|
||||
{licenseInfo.maxAllowedUsers}
|
||||
</Text>
|
||||
<Text component="span" c="dimmed">
|
||||
{" "}
|
||||
{t("workspace.people.license.users", "users")}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
{t("workspace.people.license.noSlotsAvailable", "No slots available")}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/settings/adminPlan')}
|
||||
>
|
||||
{t('workspace.people.actions.upgrade', 'Upgrade')}
|
||||
<Button size="compact-sm" variant="outline" onClick={() => navigate("/settings/adminPlan")}>
|
||||
{t("workspace.people.actions.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
@@ -384,25 +457,26 @@ export default function PeopleSection() {
|
||||
<Text size="sm" c="dimmed" span>
|
||||
•
|
||||
<Text component="span" ml={4}>
|
||||
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
|
||||
{t("workspace.people.license.grandfatheredShort", "{{count}} grandfathered", {
|
||||
count: licenseInfo.grandfatheredUserCount,
|
||||
})}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||
+{licenseInfo.licenseMaxUsers} {t("workspace.people.license.fromLicense", "from license")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Enterprise Seat Management Button */}
|
||||
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
|
||||
{globalLicenseInfo?.licenseType === "ENTERPRISE" && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" span>•</Text>
|
||||
<UpdateSeatsButton
|
||||
size="xs"
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" span>
|
||||
•
|
||||
</Text>
|
||||
<UpdateSeatsButton size="xs" onSuccess={fetchData} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
@@ -411,7 +485,7 @@ export default function PeopleSection() {
|
||||
{/* Header Actions */}
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
placeholder={t('workspace.people.searchMembers')}
|
||||
placeholder={t("workspace.people.searchMembers")}
|
||||
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
@@ -428,7 +502,7 @@ export default function PeopleSection() {
|
||||
onClick={handleAddMembersClick}
|
||||
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
|
||||
>
|
||||
{t('workspace.people.addMembers')}
|
||||
{t("workspace.people.addMembers")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
@@ -438,20 +512,25 @@ export default function PeopleSection() {
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
|
||||
{t("workspace.people.user")}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w={100}>
|
||||
{t("workspace.people.role")}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.team')}
|
||||
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
|
||||
{t("workspace.people.team")}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}>
|
||||
{t("workspace.people.mfaTitle", "MFA")}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
@@ -461,7 +540,7 @@ export default function PeopleSection() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
{t("workspace.people.noMembersFound")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -469,28 +548,28 @@ export default function PeopleSection() {
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr
|
||||
key={user.id}
|
||||
style={isCurrentUser(user) ? { backgroundColor: 'rgba(34, 139, 230, 0.08)' } : undefined}
|
||||
style={isCurrentUser(user) ? { backgroundColor: "rgba(34, 139, 230, 0.08)" } : undefined}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
? t("workspace.people.disabled", "Disabled")
|
||||
: user.isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
? t("workspace.people.activeSession", "Active session")
|
||||
: t("workspace.people.active", "Active")
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? 'blue' : 'gray'}
|
||||
color={user.enabled ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
border: user.isActive ? "2px solid var(--mantine-color-green-6)" : "none",
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
@@ -505,9 +584,9 @@ export default function PeopleSection() {
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
@@ -522,14 +601,10 @@ export default function PeopleSection() {
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'cyan'}
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin', 'Admin')
|
||||
: t('workspace.people.member', 'Member')}
|
||||
<Badge size="sm" variant="light" color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}>
|
||||
{(user.rolesAsString || "").includes("ROLE_ADMIN")
|
||||
? t("workspace.people.admin", "Admin")
|
||||
: t("workspace.people.member", "Member")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -539,9 +614,9 @@ export default function PeopleSection() {
|
||||
size="sm"
|
||||
maw={150}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user.team.name}
|
||||
@@ -551,36 +626,62 @@ export default function PeopleSection() {
|
||||
<Text size="sm">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
|
||||
<Text size="xs">
|
||||
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
|
||||
? new Date(user.lastRequest).toLocaleString()
|
||||
:t('never', 'Never')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
position="left"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle"size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Table.Td>
|
||||
{!user.mfaEnabled && user.mfaRequired ? (
|
||||
// shield icon when MFA is required
|
||||
<Tooltip label={t("workspace.people.mfa.required", "MFA Required")} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<LocalIcon icon="shield-lock" width="1rem" height="1rem" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!user.mfaEnabled && !user.mfaRequired ? (
|
||||
// dash when MFA is not required
|
||||
<Tooltip
|
||||
label={t("workspace.people.mfa.notRequired", "MFA Not Required")}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<LocalIcon icon="shield-question-rounded" width="1rem" height="1rem" />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{user.mfaEnabled && (
|
||||
// key icon when MFA is enabled
|
||||
<Tooltip label={t("workspace.people.mfa.enabled", "MFA Enabled")} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<LocalIcon icon="key" width="1rem" height="1rem" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={500}>
|
||||
Authentication: {user.authenticationType || "Unknown"}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Last Activity:{" "}
|
||||
{user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
|
||||
? new Date(user.lastRequest).toLocaleString()
|
||||
: t("never", "Never")}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
position="left"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
{!isCurrentUser(user) && (
|
||||
{/* Actions menu */}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" disabled={!loginEnabled}>
|
||||
<ActionIcon variant="subtle" disabled={!loginEnabled}>
|
||||
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
@@ -591,25 +692,31 @@ export default function PeopleSection() {
|
||||
onClick={() => openEditModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.editRole', 'Edit Role & Team')}
|
||||
{t("workspace.people.editRole", "Edit Role & Team")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.changePassword.action', 'Change password')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.people.changePassword.action", "Change password")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
leftSection={
|
||||
user.enabled ? (
|
||||
<LocalIcon icon="person-off" width="1rem" height="1rem" />
|
||||
) : (
|
||||
<LocalIcon icon="person-check" width="1rem" height="1rem" />
|
||||
)
|
||||
}
|
||||
onClick={async () => handleToggleEnabled(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
|
||||
{user.enabled ? t("workspace.people.disable") : t("workspace.people.enable")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!isCurrentUser(user) && user.mfaEnabled && (
|
||||
@@ -618,50 +725,66 @@ export default function PeopleSection() {
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await userManagementService.disableMfaByAdmin(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
|
||||
} catch (error: any) {
|
||||
console.error('[PeopleSection] Failed to disable MFA for user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.mfa.adminDisableError', 'Failed to disable MFA for user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
}}
|
||||
onClick={async () => handleDisableMfaByAdmin(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
|
||||
{t("workspace.people.mfa.disableByAdmin", "Disable MFA")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentUser(user) && !user.mfaEnabled && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
|
||||
onClick={async () => handleSetMfaButton(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{user.mfaRequired
|
||||
? t("workspace.people.mfa.setOptional", "Set MFA Optional")
|
||||
: t("workspace.people.mfa.setRequired", "Set MFA Require")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
{!isCurrentUser(user) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
|
||||
{t('workspace.people.deleteUser')}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
|
||||
onClick={async () => handleDeleteUser(user)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.people.deleteUser")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* link to Account */}
|
||||
{isCurrentUser(user) && (
|
||||
<Tooltip
|
||||
label={t("workspace.people.viewProfile", "View your profile")}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<ActionIcon variant="subtle" onClick={() => navigate("/settings/account")} disabled={!loginEnabled}>
|
||||
<LocalIcon icon="account-circle" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Invite Members Modal (reusable) */}
|
||||
<InviteMembersModal
|
||||
opened={inviteModalOpened}
|
||||
onClose={() => setInviteModalOpened(false)}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
<InviteMembersModal opened={inviteModalOpened} onClose={() => setInviteModalOpened(false)} onSuccess={fetchData} />
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
@@ -686,34 +809,34 @@ export default function PeopleSection() {
|
||||
onClick={closeEditModal}
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.people.editMember.title')}
|
||||
{t("workspace.people.editMember.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
|
||||
{t("workspace.people.editMember.editing")} <strong>{selectedUser?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
label={t('workspace.people.editMember.role')}
|
||||
label={t("workspace.people.editMember.role")}
|
||||
data={roleOptions}
|
||||
value={editForm.role}
|
||||
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
|
||||
onChange={(value) => setEditForm({ ...editForm, role: value || "ROLE_USER" })}
|
||||
renderOption={renderRoleOption}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
label={t('workspace.people.editMember.team')}
|
||||
placeholder={t('workspace.people.editMember.teamPlaceholder')}
|
||||
label={t("workspace.people.editMember.team")}
|
||||
placeholder={t("workspace.people.editMember.teamPlaceholder")}
|
||||
data={teamOptions}
|
||||
value={editForm.teamId?.toString()}
|
||||
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
|
||||
@@ -721,7 +844,7 @@ export default function PeopleSection() {
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.people.editMember.submit')}
|
||||
{t("workspace.people.editMember.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface User {
|
||||
mfaRequired: boolean;
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
@@ -40,6 +41,7 @@ export interface AdminSettingsData {
|
||||
premiumEnabled: boolean;
|
||||
mailEnabled: boolean;
|
||||
userSettings?: Record<string, any>;
|
||||
mfaRequired: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
@@ -305,4 +307,15 @@ export const userManagementService = {
|
||||
await apiClient.post(`/api/v1/auth/mfa/disable/admin/${encodeURIComponent(username)}`, undefined);
|
||||
},
|
||||
|
||||
/**
|
||||
* Require MFA for a user (admin only)
|
||||
*/
|
||||
async requireMfaByAdmin(username: string, active: boolean): Promise<void> {
|
||||
if (!active) {
|
||||
await apiClient.post(`/api/v1/auth/mfa/optional/admin/${encodeURIComponent(username)}`, undefined);
|
||||
return;
|
||||
}
|
||||
await apiClient.post(`/api/v1/auth/mfa/require/admin/${encodeURIComponent(username)}`, undefined);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.5.0',
|
||||
appVersion: '2.5.1',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
@@ -71,13 +71,14 @@ function WidgetInputInner({
|
||||
height,
|
||||
zIndex: 10,
|
||||
boxSizing: 'border-box',
|
||||
border: `2px solid ${borderColor}`,
|
||||
borderRadius: 2,
|
||||
background: bgColor,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 1,
|
||||
background: isActive ? bgColor : 'transparent',
|
||||
transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s',
|
||||
boxShadow: isActive
|
||||
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
|
||||
: 'none',
|
||||
boxShadow:
|
||||
isActive && field.type !== 'radio' && field.type !== 'checkbox'
|
||||
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
|
||||
: 'none',
|
||||
cursor: field.readOnly ? 'default' : 'text',
|
||||
pointerEvents: 'auto',
|
||||
display: 'flex',
|
||||
@@ -122,8 +123,8 @@ function WidgetInputInner({
|
||||
const fontSize = widget.fontSize
|
||||
? widget.fontSize * scaleY
|
||||
: field.multiline
|
||||
? Math.max(8, Math.min(height * 0.65, 14))
|
||||
: Math.max(8, height * 0.7);
|
||||
? Math.max(6, Math.min(height * 0.60, 14))
|
||||
: Math.max(6, height * 0.65);
|
||||
|
||||
const inputBaseStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
@@ -192,9 +193,11 @@ function WidgetInputInner({
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
border: isActive ? commonStyle.border : '1px solid rgba(0,0,0,0.15)',
|
||||
background: isActive ? bgColor : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'center', // Keep center for checkboxes as they are usually square hitboxes
|
||||
cursor: field.readOnly ? 'default' : 'pointer',
|
||||
}}
|
||||
title={error || field.tooltip || field.label}
|
||||
@@ -207,11 +210,22 @@ function WidgetInputInner({
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${Math.max(12, height * 0.7)}px`,
|
||||
width: '85%',
|
||||
height: '85%',
|
||||
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
|
||||
maxHeight: width * 0.9,
|
||||
fontSize: `${Math.max(10, height * 0.75)}px`,
|
||||
lineHeight: 1,
|
||||
color: isChecked ? '#2196F3' : 'transparent',
|
||||
background: '#FFF',
|
||||
border: isChecked || isActive ? '1px solid #2196F3' : '1.5px solid #666',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
userSelect: 'none',
|
||||
boxShadow: isActive ? '0 0 0 2px rgba(33, 150, 243, 0.2)' : 'none',
|
||||
}}
|
||||
>
|
||||
✓
|
||||
@@ -282,9 +296,12 @@ function WidgetInputInner({
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
border: isActive ? commonStyle.border : 'none',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start', // Align to start (left) instead of center for radio buttons
|
||||
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
|
||||
cursor: field.readOnly ? 'default' : 'pointer',
|
||||
}}
|
||||
title={error || field.tooltip || `${field.label}: ${optionValue}`}
|
||||
@@ -297,12 +314,16 @@ function WidgetInputInner({
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: Math.max(8, height * 0.5),
|
||||
height: Math.max(8, height * 0.5),
|
||||
width: Math.min(width, height) * 0.8,
|
||||
height: Math.min(width, height) * 0.8,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #666',
|
||||
background: isSelected ? '#2196F3' : 'transparent',
|
||||
display: 'block',
|
||||
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
|
||||
background: isSelected ? '#2196F3' : '#FFF',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: isSelected ? 'inset 0 0 0 2px white' : 'none',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,7 @@ export async function fetchFormFieldsWithCoordinates(
|
||||
|
||||
const response = await apiClient.post<FormField[]>(
|
||||
'/api/v1/form/fields-with-coordinates',
|
||||
formData,
|
||||
{
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}
|
||||
formData
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -42,7 +39,6 @@ export async function fillFormFields(
|
||||
formData.append('flatten', String(flatten));
|
||||
|
||||
const response = await apiClient.post('/api/v1/form/fill', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
SECURITY_LOGINMETHOD: "${SECURITY_LOGINMETHOD:-all}"
|
||||
SYSTEM_DEFAULTLOCALE: en-US
|
||||
SYSTEM_DEFAULTLOCALE: "${SYSTEM_DEFAULTLOCALE:-en-US}"
|
||||
SYSTEM_BACKENDURL: "http://localhost:8080"
|
||||
|
||||
# Enterprise License (required for SAML)
|
||||
|
||||
@@ -13,24 +13,48 @@ echo -e "${BLUE}╚════════════════════
|
||||
echo ""
|
||||
|
||||
AUTO_LOGIN=false
|
||||
DEFAULT_LANGUAGE="en-US"
|
||||
COMPOSE_UP_ARGS=(-d --build)
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--auto)
|
||||
AUTO_LOGIN=true
|
||||
shift
|
||||
;;
|
||||
--nobuild)
|
||||
COMPOSE_UP_ARGS=(-d)
|
||||
shift
|
||||
;;
|
||||
--language)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo -e "${RED}Missing value for --language${NC}"
|
||||
exit 1
|
||||
fi
|
||||
DEFAULT_LANGUAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--language=*)
|
||||
DEFAULT_LANGUAGE="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-l)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo -e "${RED}Missing value for -l${NC}"
|
||||
exit 1
|
||||
fi
|
||||
DEFAULT_LANGUAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--auto] [--nobuild]"
|
||||
echo "Usage: $0 [--auto] [--nobuild] [--language <locale>]"
|
||||
echo ""
|
||||
echo " --auto Enable SSO auto-login and force SAML-only login method"
|
||||
echo " --nobuild Skip building images (use existing images)"
|
||||
echo " --language Set system default locale (e.g. de-DE, sv-SE)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown option: $arg${NC}"
|
||||
echo -e "${RED}Unknown option: $1${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -65,6 +89,10 @@ if [ "$AUTO_LOGIN" = true ]; then
|
||||
echo ""
|
||||
fi
|
||||
|
||||
export SYSTEM_DEFAULTLOCALE="$DEFAULT_LANGUAGE"
|
||||
echo -e "${GREEN}✓ Default locale set to: ${SYSTEM_DEFAULTLOCALE}${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${YELLOW}▶ Starting Keycloak (SAML) containers...${NC}"
|
||||
docker-compose -f docker-compose-keycloak-saml.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-saml-db keycloak-saml
|
||||
|
||||
|
||||
Reference in New Issue
Block a user