staffList;
@@ -124,23 +135,34 @@ public StaffDTO updateStatus(Long staffId, String status, Staff requester) {
return toDtoWithSsnPolicy(savedStaff, requester); // Returnerar maskat SSN efter statusuppdatering
}
+ private void validateSsnNotExists(String ssn) {
+ String ssnHash = encryptionService.hmac(ssn);
+
+ // Kontrollera om SSN redan finns i Staff
+ if (staffRepository.existsBySsnHash(ssnHash)) {
+ throw new IllegalStateException("An employee with this SSN already exists.");
+ }
+ }
// ADMIN/HR får se hela, andra får se maskat.
private StaffDTO toDtoWithSsnPolicy(Staff staff, Staff requester) {
StaffDTO dto = staffMapper.toDto(staff);
String rawEncryptedSsn = staff.getSocialSecurityNumber();
- if (requester != null && (requester.getRole() == Role.ADMIN || requester.getRole() == Role.HR)) {
- dto.setSocialSecurityNumber(encryptionService.decrypt(rawEncryptedSsn));
+ String plainText = encryptionService.decrypt(rawEncryptedSsn);
+
+ boolean isPrivileged = requester != null && (
+ requester.getRole() == Role.ADMIN ||
+ requester.getRole() == Role.HR ||
+ Objects.equals(requester.getId(), staff.getId())
+ );
+
+ if (isPrivileged) {
+ dto.setSocialSecurityNumber(plainText);
} else {
- dto.setSocialSecurityNumber(encryptionService.maskLastFour(rawEncryptedSsn));
+ dto.setSocialSecurityNumber(encryptionService.maskLastFour(plainText));
}
return dto;
}
- private StaffDTO toMaskedDto(Staff staff) {
- StaffDTO dto = staffMapper.toDto(staff);
- dto.setSocialSecurityNumber(encryptionService.maskLastFour(staff.getSocialSecurityNumber()));
- return dto;
- }
}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 405af20..254d7f4 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -17,8 +17,9 @@ app.s3.allowed-origins=http://localhost:8080
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=email,profile
-app.encryption.key=${ENCRYPTION_KEY}
-app.encryption.salt=${ENCRYPTION_SALT}
+app.encryption.password=${ENCRYPTION_PASSWORD}
+app.encryption.pepper=${ENCRYPTION_PEPPER}
+app.encryption.hmac-key=${APP_ENCRYPTION_HMAC_KEY}
management.endpoints.web.exposure.include=logfile
management.endpoint.logfile.enabled=true
diff --git a/src/main/resources/db/migration/V12__add_ssn_hash.sql b/src/main/resources/db/migration/V12__add_ssn_hash.sql
new file mode 100644
index 0000000..fe4b54f
--- /dev/null
+++ b/src/main/resources/db/migration/V12__add_ssn_hash.sql
@@ -0,0 +1,12 @@
+ALTER TABLE staff
+ ADD COLUMN ssn_hash VARCHAR(64) NOT NULL;
+ALTER TABLE employment_form
+ ADD COLUMN ssn_hash VARCHAR(64) NOT NULL;
+
+--För att snabbt och enkelt kunna söka och jämföra i databasen
+CREATE UNIQUE INDEX idx_staff_ssn_hash ON staff (ssn_hash);
+CREATE UNIQUE INDEX idx_form_ssn_hash ON employment_form (ssn_hash);
+
+--Uniqueness sköts via ssn_hash istället
+ALTER TABLE staff DROP CONSTRAINT IF EXISTS uc_staff_social_security_number;
+ALTER TABLE employment_form DROP CONSTRAINT IF EXISTS uc_employmentform_social_security_number;
diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css
index 295cdad..d28d446 100644
--- a/src/main/resources/static/css/styles.css
+++ b/src/main/resources/static/css/styles.css
@@ -215,6 +215,63 @@ input, select, textarea {
border: 1px solid var(--border);
}
+/* SSN style */
+.ssn-wrapper {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ padding: 4px 10px;
+ background: #f1f5f9;
+ border-radius: 8px;
+}
+
+.ssn-display-row {
+ display: flex;
+ align-items: center; /* Centrerar "SSN:" vertikalt med boxen */
+ gap: 8px; /* Avståndet mellan texten och den grå boxen */
+ margin-top: 0.5rem;
+}
+
+.ssn-label {
+ font-weight: 700; /* Samma fetstil som "Status:" */
+ color: #1e293b;
+ min-width: 45px; /* Valfritt: ger alla etiketter samma startpunkt om du vill */
+}
+
+.ssn-placeholder {
+ color: #94a3b8;
+ letter-spacing: 2px;
+ font-weight: bold;
+}
+
+.ssn-value {
+ font-family: 'Courier New', monospace;
+ color: #1e293b;
+ font-size: 0.95rem;
+}
+
+.btn-ssn-action {
+ background: white;
+ border: 1px solid #cbd5e0;
+ border-radius: 6px;
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #475569;
+ cursor: pointer;
+ text-transform: uppercase;
+}
+
+.btn-ssn-action:hover {
+ background: #f8fafc;
+ border-color: #94a3b8;
+}
+
+.btn-ssn-action.active {
+ background: #f1f5f9;
+ border-color: #94a3b8;
+}
+
/* Login */
.login-body {
background: radial-gradient(circle at center, #065f46, #042f24);
diff --git a/src/main/resources/static/js/employment.js b/src/main/resources/static/js/employment.js
index d816875..d908640 100644
--- a/src/main/resources/static/js/employment.js
+++ b/src/main/resources/static/js/employment.js
@@ -20,6 +20,20 @@ async function initEmploymentPage() {
}
}
+function getInitialSsnHtml(id, type) {
+ return `
+
+
SSN:
+
+ ••••••-••••
+
+
+
+ `;
+}
+
function setupEmploymentPageButtons() {
const openBtn = document.getElementById("openCreateEmploymentBtn");
const cancelBtn = document.getElementById("cancelCreateEmploymentBtn");
@@ -120,17 +134,64 @@ function renderEmploymentStaffList() {
-
ID: ${staff.id}
Email: ${escapeHtml(staff.email || "-")}
-
Telefon: ${escapeHtml(staff.phoneNumber || "-")}
+
Phone: ${escapeHtml(staff.phoneNumber || "-")}
Department: ${escapeHtml(staff.department || "-")}
-
Personnummer: ${escapeHtml(staff.socialSecurityNumber || "-")}
Status: ${escapeHtml(staff.status || "OFFLINE")}
+
+
+ ${getInitialSsnHtml(staff.id, 'staff')}
+
`).join("");
}
+async function toggleSsnView(id, type) {
+ // Bestäm prefix och endpoint baserat på typen
+ const prefix = (type === 'staff') ? 'ssn' : 'form-ssn';
+ const endpoint = (type === 'staff') ? 'staff' : 'forms';
+
+ const container = document.getElementById(`${prefix}-container-${id}`);
+ if (!container) return;
+
+ container.innerHTML = "Laddar...";
+
+ try {
+ const res = await apiFetch(`/${endpoint}/${id}`);
+ if (!res.ok) throw new Error("Behörighet saknas");
+
+ const data = await res.json();
+
+ container.innerHTML = `
+
+
SSN:
+
+ ${escapeHtml(data.socialSecurityNumber)}
+
+
+
+`;
+
+ // Maskera automatiskt efter 10 sekunder
+ setTimeout(() => resetSsnView(id, type), 10000);
+
+ } catch (e) {
+ container.innerHTML = "Kunde inte hämta data.";
+ console.error(e);
+ }
+}
+
+function resetSsnView(id, type) {
+ const prefix = (type === 'staff') ? 'ssn' : 'form-ssn';
+ const container = document.getElementById(`${prefix}-container-${id}`);
+ if (container) {
+ container.innerHTML = getInitialSsnHtml(id, type);
+ }
+}
+
async function submitEmploymentForm(e) {
e.preventDefault();
@@ -200,11 +261,13 @@ async function loadPendingEmploymentForms() {
Email: ${escapeHtml(form.email)}
-
Telefon: ${escapeHtml(form.phoneNumber)}
-
Roll: ${escapeHtml(form.role)}
+
Phone: ${escapeHtml(form.phoneNumber)}
+
Role: ${escapeHtml(form.role)}
Department: ${escapeHtml(form.department)}
-
Personnummer: ${escapeHtml(form.socialSecurityNumber)}
-
+
+ ${getInitialSsnHtml(form.id, 'form')}
+
+
${canApprove ? `
diff --git a/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java b/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java
index b8601b0..60bccce 100644
--- a/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java
+++ b/src/test/java/org/example/cyberwatch/features/form/service/EmploymentFormServiceTest.java
@@ -24,7 +24,6 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import tools.jackson.databind.ObjectMapper;
-import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -66,6 +65,7 @@ void createForm_Success() {
// Arrange
String rawSsn = "19900101-1234";
String encryptedSsn = "krypterat-ssn";
+ String ssnHash = "abc-123-hash";
CreateEmploymentDTO dto = new CreateEmploymentDTO(
rawSsn, "Alice", "Andersson",
@@ -73,9 +73,12 @@ void createForm_Success() {
);
Staff hrStaff = createHrStaff();
EmploymentForm entity = new EmploymentForm();
+ entity.setId(1L);
+
+ when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash);
+ when(formRepository.existsBySsnHash(ssnHash)).thenReturn(false);
+ when(staffRepository.existsBySsnHash(ssnHash)).thenReturn(false);
- when(formRepository.findAll()).thenReturn(List.of());
- when(staffRepository.findAll()).thenReturn(List.of());
when(encryptionService.encrypt(rawSsn)).thenReturn(encryptedSsn);
when(mapper.toEntity(dto)).thenReturn(entity);
when(formRepository.save(any(EmploymentForm.class))).thenReturn(entity);
@@ -176,22 +179,22 @@ void approveAndFinalize_Success() throws Exception {
@DisplayName("Should throw exception if SSN already exists in form repository")
void createForm_DuplicateSsnInForms_ThrowsException() {
// Arrange
- Staff creator = new Staff();
- creator.setEmail("owner@cyberwatch.local");
+ String rawSsn = "19900101-1234";
+ String ssnHash = "existing-staff-hash";
CreateEmploymentDTO dto = new CreateEmploymentDTO(
- "19900101-1234", "Alice", "Andersson",
+ rawSsn, "Alice", "Andersson",
"alice@test.com", "070", Role.HR, Department.BACKEND, null, null, null
);
- EmploymentForm existing = new EmploymentForm();
- existing.setSocialSecurityNumber("krypterat-ssn");
+ Staff creator = createHrStaff();
- when(formRepository.findAll()).thenReturn(List.of(existing));
- when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
+ when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash);
+ when(formRepository.existsBySsnHash(ssnHash)).thenReturn(true); // Finns redan i forms
// Act & Assert
assertThrows(IllegalStateException.class, () ->
service.createForm(dto, creator)
);
+
verify(formRepository, never()).save(any());
}
@@ -199,20 +202,17 @@ void createForm_DuplicateSsnInForms_ThrowsException() {
@DisplayName("Should throw exception if SSN already exists in staff repository")
void createForm_DuplicateSsnInStaff_ThrowsException() {
// Arrange
- Staff creator = new Staff();
String rawSsn = "19900101-1234";
-
+ String ssnHash = "existing-staff-hash";
CreateEmploymentDTO dto = new CreateEmploymentDTO(
rawSsn, "Alice", "Andersson",
"alice@test.com", "070", Role.HR, Department.BACKEND, null, null, null
);
+ Staff creator = createHrStaff();
- Staff existingStaff = new Staff();
- existingStaff.setSocialSecurityNumber("krypterat-ssn");
-
- when(formRepository.findAll()).thenReturn(List.of());//no match
- when(staffRepository.findAll()).thenReturn(List.of(existingStaff));
- when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
+ when(encryptionService.hmac(rawSsn)).thenReturn(ssnHash);
+ when(formRepository.existsBySsnHash(ssnHash)).thenReturn(false); // Finns inte i forms
+ when(staffRepository.existsBySsnHash(ssnHash)).thenReturn(true); // Finns i staff
// Act & Assert
assertThrows(IllegalStateException.class, () ->
diff --git a/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java b/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java
index d3a3cc7..d8a339e 100644
--- a/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java
+++ b/src/test/java/org/example/cyberwatch/features/staff/service/StaffServiceTest.java
@@ -86,16 +86,18 @@ void updateStatus_ShouldUpdateAndReturnStaffDTO() {
@Test
@DisplayName("Should return masked SSN for non-admin")
void getStaffById_NonAdminOrHr_ReturnsMaskedSsn() {
- newStaff.setRole(Role.CONSULTANT);
+ Staff requester = new Staff();
+ requester.setId(99L);
+ requester.setRole(Role.CONSULTANT);
+
when(staffRepository.findById(1L)).thenReturn(Optional.of(newStaff));
when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO);
- when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****");
+ when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
+ when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****");
- StaffDTO result = staffService.getStaffById(1L, newStaff);
+ StaffDTO result = staffService.getStaffById(1L, requester);
assertThat(result.getSocialSecurityNumber()).isEqualTo("19900101-****");
- verify(encryptionService).maskLastFour("krypterat-ssn");
- verify(encryptionService, never()).decrypt(any());
}
@Test
@@ -133,10 +135,13 @@ void getStaffById_NullId() {
@DisplayName("Should update and return DTO when valid input")
void updateStaff_Valid() {
newStaff.setRole(Role.ADMIN);
- UpdateStaffDTO dto = new UpdateStaffDTO("Anna", "Nilsson",
+ String rawSsn = "19900101-1234";
+ UpdateStaffDTO dto = new UpdateStaffDTO("Anna", "Nilsson", rawSsn,
"anna@cyberwatch.se", "0701234567", Role.HR, Department.BACKEND);
when(staffRepository.findById(1L)).thenReturn(Optional.of(newStaff));
+ when(encryptionService.decrypt("krypterat-ssn")).thenReturn(rawSsn);
+
when(staffRepository.save(newStaff)).thenReturn(newStaff);
when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO);
@@ -179,26 +184,34 @@ void deleteStaff_NotFound() {
@Test
@DisplayName("Should filter by role and mask SSN for non-admin or hr")
void getStaffByRoleOrDepartment_FilterByRole_MaskedSsn() {
- newStaff.setRole(Role.CEO);
+ Staff requester = new Staff();
+ requester.setRole(Role.CONSULTANT);
+
when(staffRepository.findByRole(Role.HR)).thenReturn(List.of(newStaff));
when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO);
- when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****");
- List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, newStaff);
+ // FIX: Nu behövs dessa även i list-vyn
+ when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
+ when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****");
+
+ List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, requester);
assertThat(result).hasSize(1);
- verify(staffRepository, times(1)).findByRole(Role.HR);
- verify(staffRepository, never()).findByDepartment(any());
+ assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-****");
}
@Test
@DisplayName("Should filter by role and decrypt SSN for admin")
void getStaffByRoleOrDepartment_FilterByRole_DecryptedSsn() {
+ Staff requester = new Staff();
+ requester.setRole(Role.ADMIN);
+
when(staffRepository.findByRole(Role.HR)).thenReturn(List.of(newStaff));
when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO);
+
when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
- List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, newStaff);
+ List result = staffService.getStaffByRoleOrDepartment(Role.HR, null, requester);
assertThat(result).hasSize(1);
assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-1234");
@@ -237,13 +250,18 @@ void getStaffByRoleOrDepartment_NoFilter() {
@Test
@DisplayName("Should return all staff when no filter with masked last four")
void getStaffByRoleOrDepartment_NoFilter_MaskedFour() {
- newStaff.setRole(Role.PROJECT_MANAGER);
+ Staff requester = new Staff();
+ requester.setRole(Role.PROJECT_MANAGER);
when(staffRepository.findAll()).thenReturn(List.of(newStaff));
when(staffMapper.toDto(newStaff)).thenReturn(newStaffDTO);
- when(encryptionService.maskLastFour("krypterat-ssn")).thenReturn("19900101-****");
- List result = staffService.getStaffByRoleOrDepartment(null, null, newStaff);
+
+ when(encryptionService.decrypt("krypterat-ssn")).thenReturn("19900101-1234");
+ when(encryptionService.maskLastFour("19900101-1234")).thenReturn("19900101-****");
+
+ List result = staffService.getStaffByRoleOrDepartment(null, null, requester);
assertThat(result).hasSize(1);
+ assertThat(result.getFirst().getSocialSecurityNumber()).isEqualTo("19900101-****");
verify(staffRepository).findAll();
}
}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
index 47e7380..d078224 100644
--- a/src/test/resources/application-test.properties
+++ b/src/test/resources/application-test.properties
@@ -1,5 +1,6 @@
-ENCRYPTION_KEY=testencryptionkey1234567890123456
-ENCRYPTION_SALT=0123456789abcdef
+app.encryption.password=testencryptionkey1234567890123456
+app.encryption.pepper=0123456789abcdef
+app.encryption.hmac-key=0123456789abcdef0123456789abcdef
S3_ENDPOINT=http://localhost:9002
S3_BUCKET=test-bucket
S3_ACCESS_KEY=minioadmin