mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
co-authored by
Claude Haiku 4.5
parent
8b25db37ad
commit
012bd1af92
@@ -5,6 +5,7 @@ bootRun {
|
|||||||
spotless {
|
spotless {
|
||||||
java {
|
java {
|
||||||
target 'src/**/java/**/*.java'
|
target 'src/**/java/**/*.java'
|
||||||
|
targetExclude 'src/main/java/org/apache/**'
|
||||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||||
|
|
||||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||||
@@ -26,6 +27,7 @@ spotless {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
|
api 'com.google.guava:guava:33.4.8-jre'
|
||||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||||
|
|||||||
@@ -467,4 +467,24 @@ public class TaskManager {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the job key that owns a given file ID.
|
||||||
|
*
|
||||||
|
* @param fileId file identifier to look up
|
||||||
|
* @return scoped job key if found, otherwise null
|
||||||
|
*/
|
||||||
|
public String findJobKeyByFileId(String fileId) {
|
||||||
|
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||||
|
JobResult jobResult = entry.getValue();
|
||||||
|
if (jobResult.hasFiles()) {
|
||||||
|
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
|
||||||
|
if (fileId.equals(resultFile.getFileId())) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,10 @@ configurations {
|
|||||||
spotless {
|
spotless {
|
||||||
java {
|
java {
|
||||||
target 'src/**/java/**/*.java'
|
target 'src/**/java/**/*.java'
|
||||||
targetExclude 'src/main/resources/static/**'
|
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
|
||||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||||
|
|
||||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||||
toggleOffOn()
|
|
||||||
trimTrailingWhitespace()
|
trimTrailingWhitespace()
|
||||||
leadingTabsToSpaces()
|
leadingTabsToSpaces()
|
||||||
endWithNewline()
|
endWithNewline()
|
||||||
@@ -91,7 +90,7 @@ dependencies {
|
|||||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||||
exclude group: 'com.google.code.gson', module: 'gson'
|
exclude group: 'com.google.code.gson', module: 'gson'
|
||||||
}
|
}
|
||||||
// CVE-2022-25647: Explicit gson 2.8.9 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
|
||||||
implementation 'com.google.code.gson:gson:2.13.2'
|
implementation 'com.google.code.gson:gson:2.13.2'
|
||||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||||
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||||
|
|||||||
@@ -262,17 +262,30 @@ public class JobController {
|
|||||||
@Operation(summary = "Get file metadata")
|
@Operation(summary = "Get file metadata")
|
||||||
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
|
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
|
||||||
try {
|
try {
|
||||||
// Verify file exists
|
String jobKey = taskManager.findJobKeyByFileId(fileId);
|
||||||
if (!fileStorage.fileExists(fileId)) {
|
if (jobKey == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateJobAccess(jobKey)) {
|
||||||
|
log.warn("Unauthorized attempt to access file metadata: {}", fileId);
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(Map.of("message", "You are not authorized to access this file"));
|
||||||
|
}
|
||||||
|
|
||||||
// Find the file metadata from any job that contains this file
|
// Find the file metadata from any job that contains this file
|
||||||
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
||||||
|
|
||||||
if (resultFile != null) {
|
if (resultFile != null) {
|
||||||
return ResponseEntity.ok(resultFile);
|
return ResponseEntity.ok(resultFile);
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (!isSecurityEnabled()) {
|
||||||
|
// Backwards compatibility when ownership service is unavailable
|
||||||
|
if (!fileStorage.fileExists(fileId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
// File exists but no metadata found, get basic info efficiently
|
// File exists but no metadata found, get basic info efficiently
|
||||||
long fileSize = fileStorage.getFileSize(fileId);
|
long fileSize = fileStorage.getFileSize(fileId);
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
@@ -286,6 +299,8 @@ public class JobController {
|
|||||||
"fileSize",
|
"fileSize",
|
||||||
fileSize));
|
fileSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
|
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
|
||||||
return ResponseEntity.internalServerError()
|
return ResponseEntity.internalServerError()
|
||||||
@@ -303,11 +318,17 @@ public class JobController {
|
|||||||
@Operation(summary = "Download a file")
|
@Operation(summary = "Download a file")
|
||||||
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
|
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
|
||||||
try {
|
try {
|
||||||
// Verify file exists
|
String jobKey = taskManager.findJobKeyByFileId(fileId);
|
||||||
if (!fileStorage.fileExists(fileId)) {
|
if (jobKey == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateJobAccess(jobKey)) {
|
||||||
|
log.warn("Unauthorized attempt to download file: {}", fileId);
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(Map.of("message", "You are not authorized to access this file"));
|
||||||
|
}
|
||||||
|
|
||||||
// Retrieve file content
|
// Retrieve file content
|
||||||
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
||||||
|
|
||||||
@@ -327,11 +348,14 @@ public class JobController {
|
|||||||
.body(fileContent);
|
.body(fileContent);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
|
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
|
||||||
return ResponseEntity.internalServerError()
|
return ResponseEntity.internalServerError().body("Error retrieving file");
|
||||||
.body("Error retrieving file: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSecurityEnabled() {
|
||||||
|
return jobOwnershipService != null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Content-Disposition header with UTF-8 filename support
|
* Create Content-Disposition header with UTF-8 filename support
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.mock.web.MockHttpSession;
|
import org.springframework.mock.web.MockHttpSession;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import stirling.software.common.model.job.JobResult;
|
import stirling.software.common.model.job.JobResult;
|
||||||
import stirling.software.common.service.FileStorage;
|
import stirling.software.common.service.FileStorage;
|
||||||
|
import stirling.software.common.service.JobOwnershipService;
|
||||||
import stirling.software.common.service.JobQueue;
|
import stirling.software.common.service.JobQueue;
|
||||||
import stirling.software.common.service.TaskManager;
|
import stirling.software.common.service.TaskManager;
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ class JobControllerTest {
|
|||||||
|
|
||||||
@Mock private HttpServletRequest request;
|
@Mock private HttpServletRequest request;
|
||||||
|
|
||||||
|
@Mock private JobOwnershipService jobOwnershipService;
|
||||||
|
|
||||||
private MockHttpSession session;
|
private MockHttpSession session;
|
||||||
|
|
||||||
@InjectMocks private JobController controller;
|
@InjectMocks private JobController controller;
|
||||||
@@ -404,4 +408,32 @@ class JobControllerTest {
|
|||||||
|
|
||||||
verify(taskManager).setError(jobId, "Job was cancelled by user");
|
verify(taskManager).setError(jobId, "Job was cancelled by user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDownloadFile_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
|
||||||
|
String fileId = "file-id";
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
|
||||||
|
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
|
||||||
|
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
|
||||||
|
|
||||||
|
ResponseEntity<?> response = controller.downloadFile(fileId);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||||
|
verify(fileStorage, never()).retrieveBytes(eq(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetFileMetadata_ForbiddenWhenFileOwnedByAnotherUser() throws Exception {
|
||||||
|
String fileId = "file-id";
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(controller, "jobOwnershipService", jobOwnershipService);
|
||||||
|
when(taskManager.findJobKeyByFileId(fileId)).thenReturn("other-user:job-id");
|
||||||
|
when(jobOwnershipService.validateJobAccess("other-user:job-id")).thenReturn(false);
|
||||||
|
|
||||||
|
ResponseEntity<?> response = controller.getFileMetadata(fileId);
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||||
|
verify(fileStorage, never()).getFileSize(eq(fileId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ bootRun {
|
|||||||
spotless {
|
spotless {
|
||||||
java {
|
java {
|
||||||
target 'src/**/java/**/*.java'
|
target 'src/**/java/**/*.java'
|
||||||
|
targetExclude 'src/main/java/org/apache/**'
|
||||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||||
|
|
||||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||||
toggleOffOn()
|
|
||||||
trimTrailingWhitespace()
|
trimTrailingWhitespace()
|
||||||
leadingTabsToSpaces()
|
leadingTabsToSpaces()
|
||||||
endWithNewline()
|
endWithNewline()
|
||||||
@@ -37,6 +37,7 @@ spotless {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':common')
|
implementation project(':common')
|
||||||
|
api 'com.google.guava:guava:33.4.8-jre'
|
||||||
|
|
||||||
api 'org.springframework:spring-jdbc'
|
api 'org.springframework:spring-jdbc'
|
||||||
api 'org.springframework:spring-webmvc'
|
api 'org.springframework:spring-webmvc'
|
||||||
|
|||||||
+35
-2
@@ -43,6 +43,7 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
|||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.SessionEntity;
|
import stirling.software.proprietary.security.model.SessionEntity;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
|
||||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
import stirling.software.proprietary.security.service.DatabaseService;
|
import stirling.software.proprietary.security.service.DatabaseService;
|
||||||
@@ -357,8 +358,12 @@ public class ProprietaryUIDataController {
|
|||||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||||
|
|
||||||
|
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
|
||||||
|
List<AdminUserSummary> userSummaries =
|
||||||
|
sortedUsers.stream().map(this::convertUserToSummary).toList();
|
||||||
|
|
||||||
AdminSettingsData data = new AdminSettingsData();
|
AdminSettingsData data = new AdminSettingsData();
|
||||||
data.setUsers(sortedUsers);
|
data.setUsers(userSummaries);
|
||||||
data.setCurrentUsername(authentication.getName());
|
data.setCurrentUsername(authentication.getName());
|
||||||
data.setRoleDetails(roleDetails);
|
data.setRoleDetails(roleDetails);
|
||||||
data.setUserSessions(userSessions);
|
data.setUserSessions(userSessions);
|
||||||
@@ -518,6 +523,34 @@ public class ProprietaryUIDataController {
|
|||||||
return ResponseEntity.ok(data);
|
return ResponseEntity.ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
|
||||||
|
* apiKey.
|
||||||
|
*/
|
||||||
|
private AdminUserSummary convertUserToSummary(User user) {
|
||||||
|
AdminUserSummary summary = new AdminUserSummary();
|
||||||
|
summary.setId(user.getId());
|
||||||
|
summary.setUsername(user.getUsername());
|
||||||
|
summary.setEmail(user.getUsername()); // Use username as email for consistency
|
||||||
|
summary.setRoleName(user.getRoleName());
|
||||||
|
summary.setRolesAsString(user.getRolesAsString());
|
||||||
|
summary.setEnabled(user.isEnabled());
|
||||||
|
summary.setIsFirstLogin(user.isFirstLogin());
|
||||||
|
summary.setAuthenticationType(user.getAuthenticationType());
|
||||||
|
summary.setCreatedAt(user.getCreatedAt());
|
||||||
|
summary.setUpdatedAt(user.getUpdatedAt());
|
||||||
|
|
||||||
|
// Map team if present
|
||||||
|
if (user.getTeam() != null) {
|
||||||
|
AdminUserSummary.TeamSummary teamSummary = new AdminUserSummary.TeamSummary();
|
||||||
|
teamSummary.setId(user.getTeam().getId());
|
||||||
|
teamSummary.setName(user.getTeam().getName());
|
||||||
|
summary.setTeam(teamSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
// Data classes
|
// Data classes
|
||||||
@Data
|
@Data
|
||||||
public static class AuditDashboardData {
|
public static class AuditDashboardData {
|
||||||
@@ -544,7 +577,7 @@ public class ProprietaryUIDataController {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class AdminSettingsData {
|
public static class AdminSettingsData {
|
||||||
private List<User> users;
|
private List<AdminUserSummary> users;
|
||||||
private String currentUsername;
|
private String currentUsername;
|
||||||
private Map<String, String> roleDetails;
|
private Map<String, String> roleDetails;
|
||||||
private Map<String, Boolean> userSessions;
|
private Map<String, Boolean> userSessions;
|
||||||
|
|||||||
+27
-2
@@ -196,8 +196,9 @@ public class AdminSettingsController {
|
|||||||
log.info("Admin updating setting: {} = {}", key, value);
|
log.info("Admin updating setting: {} = {}", key, value);
|
||||||
GeneralUtils.saveKeyToSettings(key, value);
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change (convert null to empty string for
|
||||||
pendingChanges.put(key, value);
|
// ConcurrentHashMap)
|
||||||
|
pendingChanges.put(key, value != null ? value : "");
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
}
|
}
|
||||||
@@ -268,6 +269,9 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask sensitive fields before returning to frontend
|
||||||
|
sectionMap = maskSensitiveFields(sectionMap);
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
"Admin requested settings section: {} (includePending={})",
|
"Admin requested settings section: {} (includePending={})",
|
||||||
sectionName,
|
sectionName,
|
||||||
@@ -404,6 +408,13 @@ public class AdminSettingsController {
|
|||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
|
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mask sensitive values before returning
|
||||||
|
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
|
||||||
|
if (isSensitiveFieldWithPath(keyName, key)) {
|
||||||
|
value = createMaskedValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
log.debug("Admin requested setting: {}", key);
|
log.debug("Admin requested setting: {}", key);
|
||||||
return ResponseEntity.ok(new SettingValueResponse(key, value));
|
return ResponseEntity.ok(new SettingValueResponse(key, value));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -441,6 +452,20 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object value = request.getValue();
|
Object value = request.getValue();
|
||||||
|
|
||||||
|
// Prevent saving masked values for sensitive fields to avoid data loss
|
||||||
|
if ("********".equals(value)) {
|
||||||
|
String keyName = key.contains(".") ? key.substring(key.lastIndexOf(".") + 1) : key;
|
||||||
|
if (isSensitiveFieldWithPath(keyName, key)) {
|
||||||
|
log.warn(
|
||||||
|
"Admin attempted to save masked value for sensitive field: {}. This operation is blocked to prevent data loss.",
|
||||||
|
key);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(
|
||||||
|
"Cannot save masked values for sensitive settings. Please provide the actual value.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.info("Admin updating single setting: {} = {}", key, value);
|
log.info("Admin updating single setting: {} = {}", key, value);
|
||||||
GeneralUtils.saveKeyToSettings(key, value);
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@ public class AuthController {
|
|||||||
log.warn("Invalid password for user: {} from IP: {}", username, ip);
|
log.warn("Invalid password for user: {} from IP: {}", username, ip);
|
||||||
loginAttemptService.loginFailed(username);
|
loginAttemptService.loginFailed(username);
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("error", "Invalid credentials"));
|
.body(Map.of("error", "Invalid username or password"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isEnabled()) {
|
if (!user.isEnabled()) {
|
||||||
|
|||||||
+15
-19
@@ -358,27 +358,23 @@ public class InviteLinkController {
|
|||||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||||
|
|
||||||
if (inviteOpt.isEmpty()) {
|
if (inviteOpt.isEmpty()) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "Invalid invite link"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
InviteToken invite = inviteOpt.get();
|
InviteToken invite = inviteOpt.get();
|
||||||
|
|
||||||
if (invite.isUsed()) {
|
if (invite.isUsed()) {
|
||||||
return ResponseEntity.status(HttpStatus.GONE)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "This invite link has already been used"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invite.isExpired()) {
|
if (invite.isExpired()) {
|
||||||
return ResponseEntity.status(HttpStatus.GONE)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "This invite link has expired"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists (only if email is pre-set)
|
// Check if user already exists (only if email is pre-set)
|
||||||
if (invite.getEmail() != null
|
if (invite.getEmail() != null
|
||||||
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
|
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "User already exists"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
@@ -391,8 +387,7 @@ public class InviteLinkController {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to validate invite token: {}", e.getMessage(), e);
|
log.error("Failed to validate invite token: {}", e.getMessage(), e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "Failed to validate invite link"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,20 +414,17 @@ public class InviteLinkController {
|
|||||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||||
|
|
||||||
if (inviteOpt.isEmpty()) {
|
if (inviteOpt.isEmpty()) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "Invalid invite link"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
InviteToken invite = inviteOpt.get();
|
InviteToken invite = inviteOpt.get();
|
||||||
|
|
||||||
if (invite.isUsed()) {
|
if (invite.isUsed()) {
|
||||||
return ResponseEntity.status(HttpStatus.GONE)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "This invite link has already been used"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invite.isExpired()) {
|
if (invite.isExpired()) {
|
||||||
return ResponseEntity.status(HttpStatus.GONE)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "This invite link has expired"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the email to use
|
// Determine the email to use
|
||||||
@@ -455,8 +447,7 @@ public class InviteLinkController {
|
|||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
return invalidInviteResponse();
|
||||||
.body(Map.of("error", "User already exists"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the user account
|
// Create the user account
|
||||||
@@ -484,7 +475,12 @@ public class InviteLinkController {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
|
.body(Map.of("error", "Failed to create account"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, String>> invalidInviteResponse() {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(Map.of("error", "Invalid invite link"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -942,7 +942,7 @@ public class UserController {
|
|||||||
public ResponseEntity<?> completeInitialSetup() {
|
public ResponseEntity<?> completeInitialSetup() {
|
||||||
try {
|
try {
|
||||||
String username = userService.getCurrentUsername();
|
String username = userService.getCurrentUsername();
|
||||||
if (username == null) {
|
if (username == null || "anonymousUser".equalsIgnoreCase(username)) {
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body("User not authenticated");
|
.body("User not authenticated");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ public class User implements UserDetails, Serializable {
|
|||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
@Column(name = "password")
|
@Column(name = "password")
|
||||||
|
@JsonIgnore
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Column(name = "apiKey")
|
@Column(name = "apiKey")
|
||||||
|
@JsonIgnore
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
@Column(name = "enabled")
|
@Column(name = "enabled")
|
||||||
|
|||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package stirling.software.proprietary.security.model.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object for admin user listings. Contains only the fields needed by the admin UI for
|
||||||
|
* displaying user information. Excludes sensitive fields like password and apiKey.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
@Schema(description = "Admin user summary for listing in admin UI - excludes sensitive fields")
|
||||||
|
public class AdminUserSummary {
|
||||||
|
|
||||||
|
@Schema(description = "User ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "Username/login identifier")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Schema(description = "User email address")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "Role name (translation key like 'adminUserSettings.admin')")
|
||||||
|
private String roleName;
|
||||||
|
|
||||||
|
@Schema(description = "Role identifier (e.g., 'ROLE_ADMIN')")
|
||||||
|
private String rolesAsString;
|
||||||
|
|
||||||
|
@Schema(description = "Whether user account is enabled")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Schema(description = "Whether this is the user's first login")
|
||||||
|
private Boolean isFirstLogin;
|
||||||
|
|
||||||
|
@Schema(description = "Authentication type (WEB, OAUTH2, SAML2)")
|
||||||
|
private String authenticationType;
|
||||||
|
|
||||||
|
@Schema(description = "Team membership (if any)")
|
||||||
|
private TeamSummary team;
|
||||||
|
|
||||||
|
@Schema(description = "User account creation timestamp")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "User account last update timestamp")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** Minimal Team DTO for admin user listings */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "Team summary (id and name only)")
|
||||||
|
public static class TeamSummary {
|
||||||
|
@Schema(description = "Team ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Schema(description = "Team name")
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -149,7 +149,7 @@ class AuthControllerLoginTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(payload)))
|
.content(objectMapper.writeValueAsString(payload)))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
.andExpect(jsonPath("$.error").value("Invalid credentials"));
|
.andExpect(jsonPath("$.error").value("Invalid username or password"));
|
||||||
|
|
||||||
verify(loginAttemptService).loginFailed("[email protected]");
|
verify(loginAttemptService).loginFailed("[email protected]");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -125,7 +125,7 @@ class InviteLinkControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
|
void validateInviteTokenReturnsNotFoundWhenExpired() throws Exception {
|
||||||
InviteToken expired = new InviteToken();
|
InviteToken expired = new InviteToken();
|
||||||
expired.setToken("abc");
|
expired.setToken("abc");
|
||||||
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||||
@@ -133,8 +133,8 @@ class InviteLinkControllerTest {
|
|||||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
||||||
.andExpect(status().isGone())
|
.andExpect(status().isNotFound())
|
||||||
.andExpect(jsonPath("$.error").value("This invite link has expired"));
|
.andExpect(jsonPath("$.error").value("Invalid invite link"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ save = "Save"
|
|||||||
saveToBrowser = "Save to Browser"
|
saveToBrowser = "Save to Browser"
|
||||||
saveUnavailable = "Save unavailable for this item"
|
saveUnavailable = "Save unavailable for this item"
|
||||||
seeDockerHub = "See Docker Hub"
|
seeDockerHub = "See Docker Hub"
|
||||||
|
editSecret = "Edit this value"
|
||||||
selectFillter = "-- Select --"
|
selectFillter = "-- Select --"
|
||||||
size = "Size"
|
size = "Size"
|
||||||
sponsor = "Sponsor"
|
sponsor = "Sponsor"
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
|
interface EditableSecretFieldProps {
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for editing sensitive fields (passwords, API keys, secrets).
|
||||||
|
*
|
||||||
|
* UX:
|
||||||
|
* - Normal password input in all scenarios EXCEPT when value is masked (********)
|
||||||
|
* - When backend returns masked value (********): Shows read-only display + Edit button
|
||||||
|
* - Click Edit to change the masked value
|
||||||
|
*/
|
||||||
|
export default function EditableSecretField({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Enter value',
|
||||||
|
disabled = false,
|
||||||
|
error,
|
||||||
|
}: EditableSecretFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [tempValue, setTempValue] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const isMasked = value === '********';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setTempValue('');
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setTempValue('');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (tempValue.trim() !== '') {
|
||||||
|
onChange(tempValue);
|
||||||
|
}
|
||||||
|
setTempValue('');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>{label}</label>}
|
||||||
|
{description && <p style={{ margin: '4px 0 12px 0', fontSize: '0.75rem', color: '#666' }}>{description}</p>}
|
||||||
|
|
||||||
|
{isMasked && !isEditing ? (
|
||||||
|
// Masked value from backend: show display + Edit button
|
||||||
|
<Group gap="xs" align="flex-end">
|
||||||
|
<TextInput
|
||||||
|
value="••••••••"
|
||||||
|
disabled
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<Tooltip label={t('editSecret')} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={disabled}
|
||||||
|
title="Edit"
|
||||||
|
aria-label="Edit secret value"
|
||||||
|
>
|
||||||
|
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
) : isEditing ? (
|
||||||
|
// Edit mode: normal password input
|
||||||
|
<PasswordInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={tempValue}
|
||||||
|
onChange={(e) => setTempValue(e.currentTarget.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') handleCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Normal password input: empty or user typing
|
||||||
|
<PasswordInput
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.currentTarget.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Switch,
|
Switch,
|
||||||
PasswordInput,
|
|
||||||
NumberInput,
|
NumberInput,
|
||||||
TagsInput,
|
TagsInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import EditableSecretField from '@app/components/shared/EditableSecretField';
|
||||||
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
|
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
|
||||||
|
|
||||||
interface ProviderCardProps {
|
interface ProviderCardProps {
|
||||||
@@ -94,13 +94,13 @@ export default function ProviderCard({
|
|||||||
|
|
||||||
case 'password':
|
case 'password':
|
||||||
return (
|
return (
|
||||||
<PasswordInput
|
<EditableSecretField
|
||||||
key={field.key}
|
key={field.key}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
description={field.description}
|
description={field.description}
|
||||||
placeholder={field.placeholder}
|
placeholder={field.placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
onChange={(newValue) => handleFieldChange(field.key, newValue)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-10
@@ -10,7 +10,6 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Group,
|
Group,
|
||||||
TextInput,
|
TextInput,
|
||||||
PasswordInput,
|
|
||||||
Select,
|
Select,
|
||||||
Badge,
|
Badge,
|
||||||
Table,
|
Table,
|
||||||
@@ -29,6 +28,7 @@ import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
|||||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||||
|
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
||||||
import apiClient from "@app/services/apiClient";
|
import apiClient from "@app/services/apiClient";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService";
|
import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService";
|
||||||
@@ -515,17 +515,15 @@ export default function AdminDatabaseSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PasswordInput
|
<Group gap="xs" align="center" mb={4}>
|
||||||
label={
|
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>{t("admin.settings.database.password.label", "Password")}</span>
|
||||||
<Group gap="xs">
|
<PendingBadge show={isFieldPending("password")} />
|
||||||
<span>{t("admin.settings.database.password.label", "Password")}</span>
|
</Group>
|
||||||
<PendingBadge show={isFieldPending("password")} />
|
<EditableSecretField
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
description={t("admin.settings.database.password.description", "Database authentication password")}
|
description={t("admin.settings.database.password.description", "Database authentication password")}
|
||||||
value={settings?.password || ""}
|
value={settings?.password || ""}
|
||||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
onChange={(value) => setSettings({ ...settings, password: value })}
|
||||||
placeholder="••••••••"
|
placeholder="Enter database password"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-9
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput, Anchor } from '@mantine/core';
|
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Anchor } from '@mantine/core';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||||
|
import EditableSecretField from '@app/components/shared/EditableSecretField';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
interface MailSettingsData {
|
interface MailSettingsData {
|
||||||
@@ -174,16 +175,15 @@ export default function AdminMailSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<PasswordInput
|
<Group gap="xs" align="center" mb={4}>
|
||||||
label={
|
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
|
||||||
<Group gap="xs">
|
<PendingBadge show={isFieldPending('password')} />
|
||||||
<span>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
|
</Group>
|
||||||
<PendingBadge show={isFieldPending('password')} />
|
<EditableSecretField
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
|
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
|
||||||
value={settings.password || ''}
|
value={settings.password || ''}
|
||||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
onChange={(value) => setSettings({ ...settings, password: value })}
|
||||||
|
placeholder="Enter SMTP password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,29 @@ export default function ApiKeys() {
|
|||||||
|
|
||||||
const copy = async (text: string, tag: string) => {
|
const copy = async (text: string, tag: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
// Try modern Clipboard API first (requires HTTPS)
|
||||||
setCopied(tag);
|
if (navigator.clipboard?.writeText) {
|
||||||
setTimeout(() => setCopied(null), 1600);
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(tag);
|
||||||
|
setTimeout(() => setCopied(null), 1600);
|
||||||
|
} else {
|
||||||
|
// Fallback for HTTP: use old execCommand method
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
if (document.execCommand('copy')) {
|
||||||
|
setCopied(tag);
|
||||||
|
setTimeout(() => setCopied(null), 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('Failed to copy:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user