Compare commits

...
23 Commits
Author SHA1 Message Date
Ludy 7e9908c3be Merge branch 'main' into mfa_20260129 2026-02-20 09:30:11 +01:00
Anthony Stirling 031477b7b5 fix publishing for tauri author (#5757)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-19 16:11:10 +00:00
Anthony Stirling 8725ba66bb ruler support (#5758)
# Description of Changes

<img width="266" height="228" alt="image"
src="https://github.com/user-attachments/assets/bcc6ec11-bd9e-4b83-a081-62149dd92f2a"
/>

<img width="882" height="335" alt="image"
src="https://github.com/user-attachments/assets/b86dbf13-6bcf-4b28-81a6-8c405358a58e"
/>

<img width="1050" height="399" alt="image"
src="https://github.com/user-attachments/assets/6a4468ed-d0f9-44ab-978a-c640d490da8b"
/>

on hover
<img width="380" height="196" alt="image"
src="https://github.com/user-attachments/assets/ba3755b3-4823-48dc-b6aa-3a0f9b0517a3"
/>

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-19 15:58:03 +00:00
James Brunton 115a24b16d Add plist file for Mac permissions (#5756)
# Description of Changes
There's speculation that the Mac app failing to connect to self-hosted
servers (#5264) is because of missing OS-permissions
[NSLocalNetworkUsageDescription](https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription).
This PR adds it in a `Info.plist` file which ships with the Tauri app
which should throw an OS permissions dialog if required.
2026-02-19 10:26:01 +00:00
Balázs Szücs c775fed17d fix(form-fill): fix hardcoded response (#5755)
# Description of Changes

Closes: #5752

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-18 11:01:58 +00:00
Anthony Stirling ae9d29abf0 large query reduction (#5754)
# Description of Changes

Reduce endpoint-availability call so that an empty param to it returns
all endpoints to avoid pointlessly large http headers


Before:
GET
/api/v1/config/endpoints-availability?endpoints=compress-pdf%2Crotate-pdf%2Cmerge-pdfs%2Csplit-pages%2Cocr-pdf
  for all 74 tools

  After:
  GET /api/v1/config/endpoints-availability
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 10:52:59 +00:00
Anthony Stirling ddf93d2b1a fixes for desktop SSO (#5751)
# Description of Changes



Race condition: The browser was opened before the deep-link listener was
registered. On slow first launches the OAuth
callback could arrive before the listener was ready. Fixed by
registering the listener first, then opening the browser
   inside .then() once the listener is confirmed active.

Double-handling: Both SetupWizard and
authService.waitForDeepLinkCompletion processed the same
sso/sso-selfhosted deep
links, calling completeSelfHostedSession and onComplete() independently.
Fixed by moving all SSO completion into
  authService and having SetupWizard defer to it.

Hardening: Removing SetupWizard's handler entirely left no fallback if
the webview reloads while the auth listener's
Promise is in flight. A selfHostedDeepLinkFlowActive flag tracks whether
authService has an active listener.
SetupWizard now acts as a fallback only when the flag is false (i.e.
after a JS context reset), preventing duplicate
  handling in the normal path while preserving resilience on reload.
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 10:46:15 +00:00
Balázs Szücs e97f93924e fix(forms): Update form field UI and behavior for better interactivity and alignment (#5747)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-18 08:49:52 +00:00
Anthony Stirling 8d5b3eb36b Fix SAML login "something went wrong" when language list = 1 (#5750)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 08:49:08 +00:00
Ludy fe60c94bef Merge branch 'main' into mfa_20260129 2026-02-12 18:43:19 +01:00
Ludy 6758723256 Update greeting from 'Hello' to 'Goodbye' 2026-02-12 18:26:05 +01:00
Ludy 2377fed045 Merge branch 'main' into mfa_20260129 2026-02-07 18:47:10 +01:00
Ludy 139d1abd02 Merge branch 'main' into mfa_20260129 2026-02-01 09:51:18 +01:00
Ludy c17a6805b2 Merge branch 'main' into mfa_20260129 2026-01-31 20:57:13 +01:00
Ludy87 397493ca4d Add MFA enforcement and related labels to en-GB locale
Introduces new translation keys for multi-factor authentication enforcement for admins and users, as well as additional MFA-related status and action labels. These changes support upcoming MFA features in the UI.
2026-01-30 22:11:05 +01:00
Ludy87 273f6213a2 Refactor MFA enforcement property access formatting
Improves readability by reformatting chained method calls for MFA enforcement property access in InitialSecuritySetup and UserController. No functional changes were made.
2026-01-30 21:07:17 +01:00
Ludy87 2025418e16 Merge branch 'mfa_20260129' of https://github.com/Stirling-Tools/Stirling-PDF into mfa_20260129 2026-01-30 20:56:56 +01:00
Ludy87 0fd1898704 Add configurable MFA enforcement for admins and users
Introduces new security settings to enforce multi-factor authentication (MFA) for admin and/or non-admin users, configurable via backend properties and frontend UI. Updates user creation and management logic to respect these settings, and enhances the admin interface to allow toggling MFA enforcement.
2026-01-30 20:55:29 +01:00
Ludy ffe93ed5a8 Merge branch 'main' into mfa_20260129 2026-01-30 17:28:59 +01:00
Ludy87 119ffbbf2f Add admin controls for requiring MFA per user
Introduces the ability for admins to require or make optional multi-factor authentication (MFA) for individual users in the PeopleSection UI. Updates the userManagementService with a new requireMfaByAdmin method and extends the User interface to include mfaRequired. The UI now displays MFA status and provides actions to set MFA as required or optional for users.
2026-01-29 18:51:26 +01:00
Ludy cd00dea6f1 Merge branch 'main' into mfa_20260129 2026-01-29 18:18:09 +01:00
Ludy87 15e4785de8 Update MfaServiceTest.java 2026-01-29 17:45:52 +01:00
Ludy87 5dcaf5306a Add admin endpoints and locking for MFA management
Introduces admin endpoints to require or make MFA optional for users, and adds pessimistic locking to user settings updates for MFA operations. Improves consistency in MFA state changes, updates related service and repository methods, and enhances test coverage for MFA setup logic.
2026-01-29 17:38:43 +01:00
42 changed files with 1726 additions and 399 deletions
@@ -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
@@ -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
@@ -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);
@@ -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);
}
@@ -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
*
@@ -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
@@ -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);
@@ -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) {
@@ -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(
@@ -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))
@@ -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
View File
@@ -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."
+8
View File
@@ -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>
+4 -2
View File
@@ -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>
@@ -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);
+22 -47
View File
@@ -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,
}
+51 -8
View File
@@ -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'));
});
});
}
@@ -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>
@@ -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)
+32 -4
View File
@@ -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