merge(iteration-1): adopt platform lombok conventions
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
# Test Evidence: Platform Lombok boilerplate retrofit
|
||||||
|
|
||||||
|
- **Test type:** Unit
|
||||||
|
- **Requirement IDs:** `Engineering policy — targeted Lombok retrofit`
|
||||||
|
- **Scenario IDs:** `Source-audit RED/GREEN`
|
||||||
|
- **Test class/method:** `N/A — reproducible source audit; behavior is covered by the affected suites below`
|
||||||
|
- **Implementation commit:** `41448903aa924dc5852db8d7bb4d9319cb9f91a7`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Platform-owned Spring collaborators, request forms, and JPA entities must not retain eligible handwritten
|
||||||
|
dependency-assignment constructors, trivial accessors, or empty persistence constructors. The retrofit must preserve
|
||||||
|
constructor visibility, form normalization, entity encapsulation, defensive copies of credential/token bytes, and all
|
||||||
|
account, authentication, SMTP, and cross-feature behavior.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The source audit searches only the 79 members classified as mechanical after reading every root/config,
|
||||||
|
`feature.account`, and `feature.integration` production type. It deliberately excludes normalized email/display-name
|
||||||
|
setters, defensive byte-array getters, domain constructors and factories, state transitions, the normalized
|
||||||
|
`AccountService` public-origin constructor, `SecretCipher` key construction, and the two-constructor JavaMail test
|
||||||
|
seam. No permanent annotation-presence test was added because annotations are an implementation detail; compilation
|
||||||
|
and production-shaped tests protect the real contracts.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
Before the retrofit the audit must find 79 eligible handwritten members and exit 1. After targeted Lombok generation,
|
||||||
|
the same audit must find none and exit 0, while the retained non-mechanical members remain explicit.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
matches=$( { rg -n '^ (public )?(AccountController|BootstrapAccessFilter|BootstrapController|BootstrapService|DatabaseUserDetailsService|SmtpController|SmtpWarningAdvice|MailDeliveryService|SmtpConfigurationService)\(' src/main/java/com/lab/labtimesheet/feature/account src/main/java/com/lab/labtimesheet/feature/integration; rg -n '^ public (String getMasterKey|void setMasterKey)\(' src/main/java/com/lab/labtimesheet/config/SecurityProperties.java; rg -n '^ public (String get(Token|Password|ConfirmPassword)|void set(Token|Password|ConfirmPassword))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/ActivationForm.java; rg -n '^ public (String get(Email|DisplayName|Password)|void setPassword)\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/BootstrapForm.java; rg -n '^ public .+ (get(Email|DisplayName|Role|StudentCode|InternshipStart|InternshipEnd)|set(Role|StudentCode|InternshipStart|InternshipEnd))\(' src/main/java/com/lab/labtimesheet/feature/account/model/dto/CreateAccountForm.java; rg -n '^ public (Long getDraftId|void setDraftId)\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpActionForm.java; rg -n '^ public .+ (get(Host|Port|SecurityMode|Username|Password|FromAddress|FromName)|set(Host|Port|SecurityMode|Username|Password|FromAddress|FromName))\(' src/main/java/com/lab/labtimesheet/feature/integration/model/dto/SmtpForm.java; rg -n '^ protected (AppUser|InternProfile|SystemState|UserActionToken|SmtpConfiguration)\(\)' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; rg -n '^ public .+ (get(Id|Email|DisplayName|PasswordHash|GlobalRole|AccountStatus|ActivatedAt|InternshipStatus|InternshipStartDate|InternshipEndDate|UserId|Purpose|ExpiresAt|UsedAt|InvalidatedAt|Status|Host|Port|SecurityMode|Username|SecretKeyVersion|FromAddress|FromName|TestedAt)|isInitialized)\(' src/main/java/com/lab/labtimesheet/feature/account/model/entity src/main/java/com/lab/labtimesheet/feature/integration/model/entity; } ); if [ -n "$matches" ]; then printf '%s\n' "$matches"; printf 'RED: eligible handwritten Lombok boilerplate remains (%s matches)\n' "$(printf '%s\n' "$matches" | wc -l | tr -d ' ')"; exit 1; fi; printf 'GREEN: no eligible handwritten Lombok boilerplate remains\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
RED: eligible handwritten Lombok boilerplate remains (79 matches)
|
||||||
|
Process exited with code 1 because the confirmed mechanical members were still handwritten.
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
The exact RED source-audit command above was repeated without alteration.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
GREEN: no eligible handwritten Lombok boilerplate remains
|
||||||
|
Process exited with code 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
./mvnw -DskipTests compile
|
||||||
|
BUILD SUCCESS — 127 production source files compiled on Java 25.
|
||||||
|
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=LabtimesheetApplicationTests,LayerStructureTest,PlatformFoundationTest,TimeConfigurationTest,SecurityResponseIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SmtpIntegrationTest test
|
||||||
|
BUILD SUCCESS — Tests run: 29, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
|
||||||
|
|
||||||
|
./mvnw test
|
||||||
|
BUILD SUCCESS — Tests run: 197, Failures: 0, Errors: 0, Skipped: 0; PostgreSQL 18.4.
|
||||||
|
|
||||||
|
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
|
||||||
|
BUILD SUCCESS — doclint reported no errors; Maven emitted 66 non-fatal missing-comment warnings across the integrated
|
||||||
|
tree, including generated default constructors/accessors.
|
||||||
|
|
||||||
|
git diff --check
|
||||||
|
No output; exit 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This source audit does not prove Lombok internals or enforce a preferred annotation spelling. The compile and
|
||||||
|
PostgreSQL-backed affected/full suites prove generated constructor/accessor compatibility with Spring binding,
|
||||||
|
Security, JPA/Hibernate, Thymeleaf, and existing cross-feature consumers. No dependency, schema, migration, token,
|
||||||
|
credential, template, container, CI, or runtime configuration was changed.
|
||||||
@@ -2,21 +2,17 @@ package com.lab.labtimesheet.config;
|
|||||||
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
/** Security material used to encrypt integration credentials at rest. */
|
/** Security material used to encrypt integration credentials at rest. */
|
||||||
@ConfigurationProperties("lab.security")
|
@ConfigurationProperties("lab.security")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
public class SecurityProperties {
|
public class SecurityProperties {
|
||||||
private String masterKey;
|
private String masterKey;
|
||||||
|
|
||||||
public String getMasterKey() {
|
|
||||||
return masterKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMasterKey(String masterKey) {
|
|
||||||
this.masterKey = masterKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes and validates the configured AES-256 master key.
|
* Decodes and validates the configured AES-256 master key.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-4
@@ -6,6 +6,8 @@ import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
|
|||||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
|
||||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.hibernate.exception.ConstraintViolationException;
|
import org.hibernate.exception.ConstraintViolationException;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@@ -20,13 +22,10 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
|
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
|
||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
class AccountController {
|
class AccountController {
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
|
|
||||||
AccountController(AccountService accounts) {
|
|
||||||
this.accounts = accounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/admin/accounts/new")
|
@GetMapping("/admin/accounts/new")
|
||||||
String newAccount(Model model) {
|
String newAccount(Model model) {
|
||||||
if (!model.containsAttribute("accountForm")) {
|
if (!model.containsAttribute("accountForm")) {
|
||||||
|
|||||||
+2
-9
@@ -7,24 +7,17 @@ import jakarta.servlet.FilterChain;
|
|||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hides all non-bootstrap application routes until durable first-Admin initialization completes.
|
* Hides all non-bootstrap application routes until durable first-Admin initialization completes.
|
||||||
* Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand.
|
* Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand.
|
||||||
*/
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||||
private final BootstrapService bootstrap;
|
private final BootstrapService bootstrap;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the pre-bootstrap access guard.
|
|
||||||
*
|
|
||||||
* @param bootstrap durable installation-state service
|
|
||||||
*/
|
|
||||||
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
|
||||||
this.bootstrap = bootstrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely.
|
* Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-4
@@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.account.controller;
|
|||||||
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
|
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
|
||||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -16,13 +18,10 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
/** Renders and processes the one-time first-Admin installation form. */
|
/** Renders and processes the one-time first-Admin installation form. */
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/bootstrap")
|
@RequestMapping("/bootstrap")
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
class BootstrapController {
|
class BootstrapController {
|
||||||
private final BootstrapService bootstrap;
|
private final BootstrapService bootstrap;
|
||||||
|
|
||||||
BootstrapController(BootstrapService bootstrap) {
|
|
||||||
this.bootstrap = bootstrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
String form(Model model) {
|
String form(Model model) {
|
||||||
requireOpen();
|
requireOpen();
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
|
|||||||
import jakarta.validation.constraints.AssertTrue;
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
|
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
|
||||||
*/
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
public class ActivationForm {
|
public class ActivationForm {
|
||||||
@NotBlank(message = "This activation link is invalid or no longer usable")
|
@NotBlank(message = "This activation link is invalid or no longer usable")
|
||||||
private String token;
|
private String token;
|
||||||
@@ -34,10 +38,4 @@ public class ActivationForm {
|
|||||||
confirmPassword = null;
|
confirmPassword = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getToken() { return token; }
|
|
||||||
public void setToken(String token) { this.token = token; }
|
|
||||||
public String getPassword() { return password; }
|
|
||||||
public void setPassword(String password) { this.password = password; }
|
|
||||||
public String getConfirmPassword() { return confirmPassword; }
|
|
||||||
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package com.lab.labtimesheet.feature.account.model.dto;
|
|||||||
import jakarta.validation.constraints.Email;
|
import jakarta.validation.constraints.Email;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated browser input for creating the first administrator.
|
* Validated browser input for creating the first administrator.
|
||||||
* The password is deliberately never copied into redirected state or repopulated after validation failure.
|
* The password is deliberately never copied into redirected state or repopulated after validation failure.
|
||||||
*/
|
*/
|
||||||
|
@Getter
|
||||||
public class BootstrapForm {
|
public class BootstrapForm {
|
||||||
@NotBlank(message = "Email is required")
|
@NotBlank(message = "Email is required")
|
||||||
@Email(message = "Enter a valid email address")
|
@Email(message = "Enter a valid email address")
|
||||||
@@ -20,29 +23,15 @@ public class BootstrapForm {
|
|||||||
|
|
||||||
@NotBlank(message = "Password is required")
|
@NotBlank(message = "Password is required")
|
||||||
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
|
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
|
||||||
|
@Setter
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
public String getEmail() {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEmail(String email) {
|
public void setEmail(String email) {
|
||||||
this.email = email == null ? null : email.trim();
|
this.email = email == null ? null : email.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDisplayName() {
|
|
||||||
return displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDisplayName(String displayName) {
|
public void setDisplayName(String displayName) {
|
||||||
this.displayName = displayName == null ? null : displayName.trim();
|
this.displayName = displayName == null ? null : displayName.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPassword() {
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPassword(String password) {
|
|
||||||
this.password = password;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-10
@@ -8,9 +8,12 @@ import jakarta.validation.constraints.Email;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
|
||||||
/** Validated, non-secret Admin input for creating an immutable-role account. */
|
/** Validated, non-secret Admin input for creating an immutable-role account. */
|
||||||
|
@Getter
|
||||||
public class CreateAccountForm {
|
public class CreateAccountForm {
|
||||||
@NotBlank(message = "Email is required")
|
@NotBlank(message = "Email is required")
|
||||||
@Email(message = "Enter a valid email address")
|
@Email(message = "Enter a valid email address")
|
||||||
@@ -22,15 +25,19 @@ public class CreateAccountForm {
|
|||||||
private String displayName;
|
private String displayName;
|
||||||
|
|
||||||
@NotNull(message = "Role is required")
|
@NotNull(message = "Role is required")
|
||||||
|
@Setter
|
||||||
private GlobalRole role;
|
private GlobalRole role;
|
||||||
|
|
||||||
@Size(max = 64, message = "Student code must contain at most 64 characters")
|
@Size(max = 64, message = "Student code must contain at most 64 characters")
|
||||||
|
@Setter
|
||||||
private String studentCode;
|
private String studentCode;
|
||||||
|
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||||
|
@Setter
|
||||||
private LocalDate internshipStart;
|
private LocalDate internshipStart;
|
||||||
|
|
||||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||||
|
@Setter
|
||||||
private LocalDate internshipEnd;
|
private LocalDate internshipEnd;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,16 +74,6 @@ public class CreateAccountForm {
|
|||||||
return hasText(value) ? value.trim() : null;
|
return hasText(value) ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getEmail() { return email; }
|
|
||||||
public void setEmail(String email) { this.email = email == null ? null : email.trim(); }
|
public void setEmail(String email) { this.email = email == null ? null : email.trim(); }
|
||||||
public String getDisplayName() { return displayName; }
|
|
||||||
public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); }
|
public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); }
|
||||||
public GlobalRole getRole() { return role; }
|
|
||||||
public void setRole(GlobalRole role) { this.role = role; }
|
|
||||||
public String getStudentCode() { return studentCode; }
|
|
||||||
public void setStudentCode(String studentCode) { this.studentCode = studentCode; }
|
|
||||||
public LocalDate getInternshipStart() { return internshipStart; }
|
|
||||||
public void setInternshipStart(LocalDate internshipStart) { this.internshipStart = internshipStart; }
|
|
||||||
public LocalDate getInternshipEnd() { return internshipEnd; }
|
|
||||||
public void setInternshipEnd(LocalDate internshipEnd) { this.internshipEnd = internshipEnd; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
|
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
|
||||||
@@ -23,29 +26,37 @@ import jakarta.persistence.Version;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "app_users")
|
@Table(name = "app_users")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class AppUser {
|
public class AppUser {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Getter
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(nullable = false, length = 320)
|
@Column(nullable = false, length = 320)
|
||||||
|
@Getter
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
@Column(name = "display_name", nullable = false, length = 120)
|
@Column(name = "display_name", nullable = false, length = 120)
|
||||||
|
@Getter
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
|
||||||
@Column(name = "password_hash", length = 255)
|
@Column(name = "password_hash", length = 255)
|
||||||
|
@Getter
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
|
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
|
||||||
|
@Getter
|
||||||
private GlobalRole globalRole;
|
private GlobalRole globalRole;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "account_status", nullable = false, length = 32)
|
@Column(name = "account_status", nullable = false, length = 32)
|
||||||
|
@Getter
|
||||||
private AccountStatus accountStatus;
|
private AccountStatus accountStatus;
|
||||||
|
|
||||||
@Column(name = "activated_at")
|
@Column(name = "activated_at")
|
||||||
|
@Getter
|
||||||
private Instant activatedAt;
|
private Instant activatedAt;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@@ -61,10 +72,6 @@ public class AppUser {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
/** Required by JPA; domain instances are created through named factories. */
|
|
||||||
protected AppUser() {
|
|
||||||
}
|
|
||||||
|
|
||||||
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
|
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
|
||||||
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
|
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
|
||||||
this.email = email;
|
this.email = email;
|
||||||
@@ -124,31 +131,4 @@ public class AppUser {
|
|||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getEmail() {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDisplayName() {
|
|
||||||
return displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPasswordHash() {
|
|
||||||
return passwordHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public GlobalRole getGlobalRole() {
|
|
||||||
return globalRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccountStatus getAccountStatus() {
|
|
||||||
return accountStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getActivatedAt() {
|
|
||||||
return activatedAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import jakarta.persistence.Enumerated;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
|
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
|
||||||
@@ -18,6 +21,7 @@ import jakarta.persistence.Version;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "intern_profiles")
|
@Table(name = "intern_profiles")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class InternProfile {
|
public class InternProfile {
|
||||||
@Id
|
@Id
|
||||||
@Column(name = "user_id")
|
@Column(name = "user_id")
|
||||||
@@ -33,13 +37,16 @@ public class InternProfile {
|
|||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
@Column(name = "internship_start_date", nullable = false)
|
@Column(name = "internship_start_date", nullable = false)
|
||||||
|
@Getter
|
||||||
private LocalDate internshipStartDate;
|
private LocalDate internshipStartDate;
|
||||||
|
|
||||||
@Column(name = "internship_end_date", nullable = false)
|
@Column(name = "internship_end_date", nullable = false)
|
||||||
|
@Getter
|
||||||
private LocalDate internshipEndDate;
|
private LocalDate internshipEndDate;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "internship_status", nullable = false, length = 24)
|
@Column(name = "internship_status", nullable = false, length = 24)
|
||||||
|
@Getter
|
||||||
private InternshipStatus internshipStatus;
|
private InternshipStatus internshipStatus;
|
||||||
|
|
||||||
@Column(name = "activated_at")
|
@Column(name = "activated_at")
|
||||||
@@ -60,10 +67,6 @@ public class InternProfile {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
/** Required by JPA; domain instances are created through {@link #notStarted}. */
|
|
||||||
protected InternProfile() {
|
|
||||||
}
|
|
||||||
|
|
||||||
private InternProfile(
|
private InternProfile(
|
||||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
@@ -105,15 +108,4 @@ public class InternProfile {
|
|||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InternshipStatus getInternshipStatus() {
|
|
||||||
return internshipStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getInternshipStartDate() {
|
|
||||||
return internshipStartDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getInternshipEndDate() {
|
|
||||||
return internshipEndDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,21 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
|
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "system_state")
|
@Table(name = "system_state")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class SystemState {
|
public class SystemState {
|
||||||
@Id
|
@Id
|
||||||
@Column(name = "singleton_id")
|
@Column(name = "singleton_id")
|
||||||
private short singletonId;
|
private short singletonId;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@Getter
|
||||||
private boolean initialized;
|
private boolean initialized;
|
||||||
|
|
||||||
@Column(name = "initialized_at")
|
@Column(name = "initialized_at")
|
||||||
@@ -38,14 +43,6 @@ public class SystemState {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
/** Required by JPA; Flyway creates the singleton row. */
|
|
||||||
protected SystemState() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isInitialized() {
|
|
||||||
return initialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks the installation initialized and retains the first Admin attribution.
|
* Marks the installation initialized and retains the first Admin attribution.
|
||||||
*
|
*
|
||||||
|
|||||||
+10
-27
@@ -12,6 +12,9 @@ import jakarta.persistence.GeneratedValue;
|
|||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
|
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
|
||||||
@@ -19,28 +22,35 @@ import jakarta.persistence.Table;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "user_action_tokens")
|
@Table(name = "user_action_tokens")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class UserActionToken {
|
public class UserActionToken {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Getter
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "user_id", nullable = false)
|
@Column(name = "user_id", nullable = false)
|
||||||
|
@Getter
|
||||||
private Long userId;
|
private Long userId;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 24)
|
@Column(nullable = false, length = 24)
|
||||||
|
@Getter
|
||||||
private TokenPurpose purpose;
|
private TokenPurpose purpose;
|
||||||
|
|
||||||
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
|
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
|
||||||
private byte[] tokenHash;
|
private byte[] tokenHash;
|
||||||
|
|
||||||
@Column(name = "expires_at", nullable = false)
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
@Getter
|
||||||
private Instant expiresAt;
|
private Instant expiresAt;
|
||||||
|
|
||||||
@Column(name = "used_at")
|
@Column(name = "used_at")
|
||||||
|
@Getter
|
||||||
private Instant usedAt;
|
private Instant usedAt;
|
||||||
|
|
||||||
@Column(name = "invalidated_at")
|
@Column(name = "invalidated_at")
|
||||||
|
@Getter
|
||||||
private Instant invalidatedAt;
|
private Instant invalidatedAt;
|
||||||
|
|
||||||
@Column(name = "issued_by_user_id")
|
@Column(name = "issued_by_user_id")
|
||||||
@@ -49,10 +59,6 @@ public class UserActionToken {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
/** Required by JPA; domain instances are created through named factories. */
|
|
||||||
protected UserActionToken() {
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.purpose = TokenPurpose.ACTIVATION;
|
this.purpose = TokenPurpose.ACTIVATION;
|
||||||
@@ -115,18 +121,6 @@ public class UserActionToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getUserId() {
|
|
||||||
return userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TokenPurpose getPurpose() {
|
|
||||||
return purpose;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a defensive copy of the persisted token hash.
|
* Returns a defensive copy of the persisted token hash.
|
||||||
*
|
*
|
||||||
@@ -136,15 +130,4 @@ public class UserActionToken {
|
|||||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Instant getExpiresAt() {
|
|
||||||
return expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getUsedAt() {
|
|
||||||
return usedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getInvalidatedAt() {
|
|
||||||
return invalidatedAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
|||||||
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
|
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
|
||||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -16,20 +18,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* Successful creation persists the first active Admin and initialization marker atomically.
|
* Successful creation persists the first active Admin and initialization marker atomically.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
public class BootstrapService {
|
public class BootstrapService {
|
||||||
private final SystemStateRepository systemStates;
|
private final SystemStateRepository systemStates;
|
||||||
private final AppUserRepository users;
|
private final AppUserRepository users;
|
||||||
private final PasswordEncoder passwords;
|
private final PasswordEncoder passwords;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
|
|
||||||
Clock clock) {
|
|
||||||
this.systemStates = systemStates;
|
|
||||||
this.users = users;
|
|
||||||
this.passwords = passwords;
|
|
||||||
this.clock = clock;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the first active Admin exactly once.
|
* Creates the first active Admin exactly once.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-4
@@ -2,6 +2,8 @@ package com.lab.labtimesheet.feature.account.service;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.core.userdetails.User;
|
import org.springframework.security.core.userdetails.User;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
@@ -11,13 +13,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
|
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
class DatabaseUserDetailsService implements UserDetailsService {
|
class DatabaseUserDetailsService implements UserDetailsService {
|
||||||
private final AppUserRepository users;
|
private final AppUserRepository users;
|
||||||
|
|
||||||
DatabaseUserDetailsService(AppUserRepository users) {
|
|
||||||
this.users = users;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the normalized account and disables authentication unless its lifecycle state is active.
|
* Loads the normalized account and disables authentication unless its lifecycle state is active.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-5
@@ -9,6 +9,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm;
|
|||||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.mail.MailException;
|
import org.springframework.mail.MailException;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -25,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/admin/smtp")
|
@RequestMapping("/admin/smtp")
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
class SmtpController {
|
class SmtpController {
|
||||||
private static final String TEST_FAILURE_MESSAGE =
|
private static final String TEST_FAILURE_MESSAGE =
|
||||||
"SMTP test failed. Verify the draft settings and server availability, then try again.";
|
"SMTP test failed. Verify the draft settings and server availability, then try again.";
|
||||||
@@ -41,11 +44,6 @@ class SmtpController {
|
|||||||
private final SmtpConfigurationService smtp;
|
private final SmtpConfigurationService smtp;
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
|
|
||||||
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
|
|
||||||
this.smtp = smtp;
|
|
||||||
this.accounts = accounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
String form(Model model) {
|
String form(Model model) {
|
||||||
return renderForm(model, null);
|
return renderForm(model, null);
|
||||||
|
|||||||
+3
-4
@@ -1,6 +1,8 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.controller;
|
package com.lab.labtimesheet.feature.integration.controller;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
|
||||||
@@ -9,13 +11,10 @@ import org.springframework.web.bind.annotation.ModelAttribute;
|
|||||||
* configuration is active.
|
* configuration is active.
|
||||||
*/
|
*/
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
class SmtpWarningAdvice {
|
class SmtpWarningAdvice {
|
||||||
private final SmtpConfigurationService smtp;
|
private final SmtpConfigurationService smtp;
|
||||||
|
|
||||||
SmtpWarningAdvice(SmtpConfigurationService smtp) {
|
|
||||||
this.smtp = smtp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ModelAttribute("smtpRestricted")
|
@ModelAttribute("smtpRestricted")
|
||||||
boolean smtpRestricted() {
|
boolean smtpRestricted() {
|
||||||
return !smtp.hasActiveConfiguration();
|
return !smtp.hasActiveConfiguration();
|
||||||
|
|||||||
+4
-7
@@ -2,18 +2,15 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
|||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
/** Validated identifier submitted by the SMTP test and activation forms. */
|
/** Validated identifier submitted by the SMTP test and activation forms. */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
public class SmtpActionForm {
|
public class SmtpActionForm {
|
||||||
@NotNull(message = "SMTP draft is required")
|
@NotNull(message = "SMTP draft is required")
|
||||||
@Positive(message = "SMTP draft is invalid")
|
@Positive(message = "SMTP draft is invalid")
|
||||||
private Long draftId;
|
private Long draftId;
|
||||||
|
|
||||||
public Long getDraftId() {
|
|
||||||
return draftId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDraftId(Long draftId) {
|
|
||||||
this.draftId = draftId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import jakarta.validation.constraints.Min;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated Admin input for an SMTP draft. The cleartext password exists only for the current request and is
|
* Validated Admin input for an SMTP draft. The cleartext password exists only for the current request and is
|
||||||
* cleared before the form is rendered again.
|
* cleared before the form is rendered again.
|
||||||
*/
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
public class SmtpForm {
|
public class SmtpForm {
|
||||||
@NotBlank(message = "Host is required")
|
@NotBlank(message = "Host is required")
|
||||||
@Size(max = 255, message = "Host must contain at most 255 characters")
|
@Size(max = 255, message = "Host must contain at most 255 characters")
|
||||||
@@ -96,18 +100,4 @@ public class SmtpForm {
|
|||||||
return value == null || value.isEmpty() ? null : value;
|
return value == null || value.isEmpty() ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getHost() { return host; }
|
|
||||||
public void setHost(String host) { this.host = host; }
|
|
||||||
public int getPort() { return port; }
|
|
||||||
public void setPort(int port) { this.port = port; }
|
|
||||||
public SecurityMode getSecurityMode() { return securityMode; }
|
|
||||||
public void setSecurityMode(SecurityMode securityMode) { this.securityMode = securityMode; }
|
|
||||||
public String getUsername() { return username; }
|
|
||||||
public void setUsername(String username) { this.username = username; }
|
|
||||||
public String getPassword() { return password; }
|
|
||||||
public void setPassword(String password) { this.password = password; }
|
|
||||||
public String getFromAddress() { return fromAddress; }
|
|
||||||
public void setFromAddress(String fromAddress) { this.fromAddress = fromAddress; }
|
|
||||||
public String getFromName() { return fromName; }
|
|
||||||
public void setFromName(String fromName) { this.fromName = fromName; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-43
@@ -15,6 +15,9 @@ import jakarta.persistence.GenerationType;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest.
|
* Versioned SMTP configuration entity whose credentials remain AES-GCM encrypted at rest.
|
||||||
@@ -22,26 +25,33 @@ import jakarta.persistence.Version;
|
|||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "smtp_configurations")
|
@Table(name = "smtp_configurations")
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
public class SmtpConfiguration {
|
public class SmtpConfiguration {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Getter
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 16)
|
@Column(nullable = false, length = 16)
|
||||||
|
@Getter
|
||||||
private SmtpStatus status;
|
private SmtpStatus status;
|
||||||
|
|
||||||
@Column(nullable = false, length = 255)
|
@Column(nullable = false, length = 255)
|
||||||
|
@Getter
|
||||||
private String host;
|
private String host;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@Getter
|
||||||
private int port;
|
private int port;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "security_mode", nullable = false, length = 16)
|
@Column(name = "security_mode", nullable = false, length = 16)
|
||||||
|
@Getter
|
||||||
private SecurityMode securityMode;
|
private SecurityMode securityMode;
|
||||||
|
|
||||||
@Column(length = 320)
|
@Column(length = 320)
|
||||||
|
@Getter
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
@Column(name = "password_ciphertext")
|
@Column(name = "password_ciphertext")
|
||||||
@@ -51,15 +61,19 @@ public class SmtpConfiguration {
|
|||||||
private byte[] passwordNonce;
|
private byte[] passwordNonce;
|
||||||
|
|
||||||
@Column(name = "secret_key_version")
|
@Column(name = "secret_key_version")
|
||||||
|
@Getter
|
||||||
private Integer secretKeyVersion;
|
private Integer secretKeyVersion;
|
||||||
|
|
||||||
@Column(name = "from_address", nullable = false, length = 320)
|
@Column(name = "from_address", nullable = false, length = 320)
|
||||||
|
@Getter
|
||||||
private String fromAddress;
|
private String fromAddress;
|
||||||
|
|
||||||
@Column(name = "from_name", nullable = false, length = 120)
|
@Column(name = "from_name", nullable = false, length = 120)
|
||||||
|
@Getter
|
||||||
private String fromName;
|
private String fromName;
|
||||||
|
|
||||||
@Column(name = "tested_at")
|
@Column(name = "tested_at")
|
||||||
|
@Getter
|
||||||
private Instant testedAt;
|
private Instant testedAt;
|
||||||
|
|
||||||
@Column(name = "tested_by_user_id")
|
@Column(name = "tested_by_user_id")
|
||||||
@@ -89,10 +103,6 @@ public class SmtpConfiguration {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
/** Required by JPA; revisions are created through {@link #draft}. */
|
|
||||||
protected SmtpConfiguration() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an editable SMTP revision with encrypted credential material.
|
* Creates an editable SMTP revision with encrypted credential material.
|
||||||
*
|
*
|
||||||
@@ -191,30 +201,6 @@ public class SmtpConfiguration {
|
|||||||
return value == null || value.isBlank() ? null : value.trim();
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public SmtpStatus getStatus() {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getHost() {
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getPort() {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
public SecurityMode getSecurityMode() {
|
|
||||||
return securityMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return a defensive copy of encrypted password bytes, or {@code null} */
|
/** @return a defensive copy of encrypted password bytes, or {@code null} */
|
||||||
public byte[] getPasswordCiphertext() {
|
public byte[] getPasswordCiphertext() {
|
||||||
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
||||||
@@ -225,19 +211,4 @@ public class SmtpConfiguration {
|
|||||||
return passwordNonce == null ? null : passwordNonce.clone();
|
return passwordNonce == null ? null : passwordNonce.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getSecretKeyVersion() {
|
|
||||||
return secretKeyVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFromAddress() {
|
|
||||||
return fromAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getFromName() {
|
|
||||||
return fromName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getTestedAt() {
|
|
||||||
return testedAt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -4,6 +4,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
|||||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -12,17 +14,12 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* Stored credentials are decrypted only while constructing the immediate adapter call.
|
* Stored credentials are decrypted only while constructing the immediate adapter call.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
public class MailDeliveryService {
|
public class MailDeliveryService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
private final SecretCipher secrets;
|
private final SecretCipher secrets;
|
||||||
private final SmtpProbe probe;
|
private final SmtpProbe probe;
|
||||||
|
|
||||||
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
|
|
||||||
this.configurations = configurations;
|
|
||||||
this.secrets = secrets;
|
|
||||||
this.probe = probe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports whether workflows may emit required email.
|
* Reports whether workflows may emit required email.
|
||||||
*
|
*
|
||||||
|
|||||||
+3
-12
@@ -11,6 +11,8 @@ import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
|||||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpSetupStatus;
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpSetupStatus;
|
||||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.core.env.Profiles;
|
import org.springframework.core.env.Profiles;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -21,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
* A changed draft loses prior test status, and an active revision is retired when its tested successor activates.
|
* A changed draft loses prior test status, and an active revision is retired when its tested successor activates.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
public class SmtpConfigurationService {
|
public class SmtpConfigurationService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
@@ -30,18 +33,6 @@ public class SmtpConfigurationService {
|
|||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final MailDeliveryService mailDelivery;
|
private final MailDeliveryService mailDelivery;
|
||||||
|
|
||||||
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
|
||||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
|
|
||||||
MailDeliveryService mailDelivery) {
|
|
||||||
this.configurations = configurations;
|
|
||||||
this.accounts = accounts;
|
|
||||||
this.secrets = secrets;
|
|
||||||
this.probe = probe;
|
|
||||||
this.environment = environment;
|
|
||||||
this.clock = clock;
|
|
||||||
this.mailDelivery = mailDelivery;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates or replaces the editable draft after validating Admin authority and environment transport rules.
|
* Creates or replaces the editable draft after validating Admin authority and environment transport rules.
|
||||||
* Any supplied password is encrypted before persistence and prior test status is cleared.
|
* Any supplied password is encrypted before persistence and prior test status is cleared.
|
||||||
|
|||||||
Reference in New Issue
Block a user