fix account and SMTP onboarding workflows
This commit is contained in:
@@ -35,7 +35,7 @@ class SecurityConfiguration {
|
||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER)))
|
||||
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", true))
|
||||
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", false))
|
||||
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
||||
.build();
|
||||
|
||||
+31
-38
@@ -1,17 +1,18 @@
|
||||
package com.lab.labtimesheet.feature.account.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
class AccountController {
|
||||
@@ -22,64 +23,56 @@ class AccountController {
|
||||
}
|
||||
|
||||
@GetMapping("/admin/accounts/new")
|
||||
String newAccount() {
|
||||
String newAccount(Model model) {
|
||||
if (!model.containsAttribute("accountForm")) {
|
||||
model.addAttribute("accountForm", new CreateAccountForm());
|
||||
}
|
||||
return "accounts/new";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/accounts")
|
||||
String create(
|
||||
@RequestParam String email,
|
||||
@RequestParam String displayName,
|
||||
@RequestParam GlobalRole role,
|
||||
@RequestParam(required = false) String studentCode,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd,
|
||||
Principal principal,
|
||||
Model model) {
|
||||
String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult,
|
||||
Principal principal) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return "accounts/new";
|
||||
}
|
||||
try {
|
||||
var result = accounts.create(
|
||||
new CreateAccountCommand(
|
||||
email, displayName, role, clean(studentCode), internshipStart, internshipEnd),
|
||||
accounts.requireActiveAdminId(principal.getName()));
|
||||
var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName()));
|
||||
return result.deliverySucceeded()
|
||||
? "redirect:/admin/accounts/new?created"
|
||||
: "redirect:/admin/accounts/new?deliveryFailed";
|
||||
} catch (DataIntegrityViolationException duplicate) {
|
||||
bindingResult.rejectValue("email", "account.email.duplicate", "An account with this email already exists");
|
||||
return "accounts/new";
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
model.addAttribute("error", exception.getMessage());
|
||||
bindingResult.reject("account.invalid", exception.getMessage());
|
||||
return "accounts/new";
|
||||
}
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
@GetMapping("/activate")
|
||||
String activationForm(@RequestParam String token, Model model) {
|
||||
model.addAttribute("token", token);
|
||||
String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) {
|
||||
if (form.getToken() == null || form.getToken().isBlank()) {
|
||||
model.addAttribute("error", "This activation link is invalid or no longer usable");
|
||||
}
|
||||
return "accounts/activate";
|
||||
}
|
||||
|
||||
@PostMapping("/activate")
|
||||
String activate(
|
||||
@RequestParam String token,
|
||||
@RequestParam String password,
|
||||
@RequestParam String confirmPassword,
|
||||
Model model) {
|
||||
if (!password.equals(confirmPassword)) {
|
||||
model.addAttribute("token", token);
|
||||
model.addAttribute("error", "Passwords do not match");
|
||||
String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
form.clearPasswords();
|
||||
return "accounts/activate";
|
||||
}
|
||||
try {
|
||||
if (accounts.activate(token, password)) {
|
||||
if (accounts.activate(form.getToken(), form.getPassword())) {
|
||||
return "redirect:/login?activated";
|
||||
}
|
||||
model.addAttribute("error", "This activation link is invalid or no longer usable");
|
||||
bindingResult.reject("activation.invalid", "This activation link is invalid or no longer usable");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
model.addAttribute("error", exception.getMessage());
|
||||
bindingResult.reject("activation.invalid", exception.getMessage());
|
||||
}
|
||||
model.addAttribute("token", token);
|
||||
form.clearPasswords();
|
||||
return "accounts/activate";
|
||||
}
|
||||
}
|
||||
|
||||
+19
-7
@@ -1,13 +1,16 @@
|
||||
package com.lab.labtimesheet.feature.account.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Controller
|
||||
@@ -20,21 +23,30 @@ class BootstrapController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
String form() {
|
||||
String form(Model model) {
|
||||
requireOpen();
|
||||
if (!model.containsAttribute("bootstrapForm")) {
|
||||
model.addAttribute("bootstrapForm", new BootstrapForm());
|
||||
}
|
||||
return "bootstrap/form";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password,
|
||||
Model model) {
|
||||
String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) {
|
||||
requireOpen();
|
||||
if (bindingResult.hasErrors()) {
|
||||
form.setPassword(null);
|
||||
return "bootstrap/form";
|
||||
}
|
||||
try {
|
||||
if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) {
|
||||
return "redirect:/login";
|
||||
if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword())
|
||||
== BootstrapService.BootstrapOutcome.CREATED) {
|
||||
return "redirect:/admin/smtp?onboarding";
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
} catch (IllegalArgumentException validation) {
|
||||
model.addAttribute("error", validation.getMessage());
|
||||
bindingResult.reject("bootstrap.invalid", validation.getMessage());
|
||||
form.setPassword(null);
|
||||
return "bootstrap/form";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
|
||||
*/
|
||||
public class ActivationForm {
|
||||
@NotBlank(message = "This activation link is invalid or no longer usable")
|
||||
private String token;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Password confirmation is required")
|
||||
private String confirmPassword;
|
||||
|
||||
/**
|
||||
* Confirms both password entries agree without exposing either value.
|
||||
*
|
||||
* @return {@code true} when confirmation matches
|
||||
*/
|
||||
@AssertTrue(message = "Passwords do not match")
|
||||
public boolean isPasswordConfirmed() {
|
||||
return password != null && password.equals(confirmPassword);
|
||||
}
|
||||
|
||||
/** Clears both cleartext password values before rendering an error response. */
|
||||
public void clearPasswords() {
|
||||
password = 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; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Validated browser input for creating the first administrator.
|
||||
* The password is deliberately never copied into redirected state or repopulated after validation failure.
|
||||
*/
|
||||
public class BootstrapForm {
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Enter a valid email address")
|
||||
@Size(max = 320, message = "Email must contain at most 320 characters")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Display name is required")
|
||||
@Size(max = 120, message = "Display name must contain at most 120 characters")
|
||||
private String displayName;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
|
||||
private String password;
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
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 String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
/** Validated, non-secret Admin input for creating an immutable-role account. */
|
||||
public class CreateAccountForm {
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Enter a valid email address")
|
||||
@Size(max = 320, message = "Email must contain at most 320 characters")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Display name is required")
|
||||
@Size(max = 120, message = "Display name must contain at most 120 characters")
|
||||
private String displayName;
|
||||
|
||||
@NotNull(message = "Role is required")
|
||||
private GlobalRole role;
|
||||
|
||||
@Size(max = 64, message = "Student code must contain at most 64 characters")
|
||||
private String studentCode;
|
||||
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
private LocalDate internshipStart;
|
||||
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
private LocalDate internshipEnd;
|
||||
|
||||
/**
|
||||
* Validates the role-dependent internship fields and their inclusive date ordering.
|
||||
*
|
||||
* @return {@code true} when Intern details are complete, or absent for non-Intern roles
|
||||
*/
|
||||
@AssertTrue(message = "Intern details are required for Intern accounts and must use a valid date range")
|
||||
public boolean isInternDetailsValid() {
|
||||
if (role == null) {
|
||||
return true;
|
||||
}
|
||||
if (role != GlobalRole.INTERN) {
|
||||
return !hasText(studentCode) && internshipStart == null && internshipEnd == null;
|
||||
}
|
||||
return hasText(studentCode) && internshipStart != null && internshipEnd != null
|
||||
&& !internshipEnd.isBefore(internshipStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts validated browser input to the account service command.
|
||||
*
|
||||
* @return normalized service command
|
||||
*/
|
||||
public CreateAccountCommand toCommand() {
|
||||
return new CreateAccountCommand(email, displayName, role, clean(studentCode), internshipStart, internshipEnd);
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
public String getEmail() { return email; }
|
||||
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 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; }
|
||||
}
|
||||
+108
-17
@@ -1,20 +1,33 @@
|
||||
package com.lab.labtimesheet.feature.integration.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpActionForm;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/admin/smtp")
|
||||
class SmtpController {
|
||||
private static final String DEFERRAL_STEP = SmtpController.class.getName() + ".deferralStep";
|
||||
private static final List<String> DEFERRAL_WARNINGS = List.of(
|
||||
"Account onboarding is disabled until SMTP is active.",
|
||||
"Activation resend is disabled until SMTP is active.",
|
||||
"Password recovery is disabled until SMTP is active.",
|
||||
"Workflow email delivery is less immediate until SMTP is active.",
|
||||
"I acknowledge this installation remains restricted until SMTP is active.");
|
||||
|
||||
private final SmtpConfigurationService smtp;
|
||||
private final AccountService accounts;
|
||||
|
||||
@@ -24,29 +37,107 @@ class SmtpController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
String form() {
|
||||
return "smtp/form";
|
||||
String form(Model model) {
|
||||
return renderForm(model, null);
|
||||
}
|
||||
|
||||
@PostMapping("/draft")
|
||||
String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode,
|
||||
@RequestParam(required = false) String username, @RequestParam(required = false) String password,
|
||||
@RequestParam String fromAddress, @RequestParam String fromName, Principal principal) {
|
||||
smtp.saveDraft(adminId(principal),
|
||||
new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName));
|
||||
return "redirect:/admin/smtp";
|
||||
String saveDraft(@Valid @ModelAttribute("smtpForm") SmtpForm form, BindingResult bindingResult,
|
||||
Principal principal, Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
form.clearPassword();
|
||||
return renderForm(model, form);
|
||||
}
|
||||
try {
|
||||
smtp.saveDraft(adminId(principal), form.toDraft());
|
||||
return "redirect:/admin/smtp?saved";
|
||||
} catch (IllegalArgumentException | IllegalStateException validation) {
|
||||
bindingResult.reject("smtp.invalid", validation.getMessage());
|
||||
form.clearPassword();
|
||||
return renderForm(model, form);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/test")
|
||||
String test(@RequestParam long draftId, Principal principal) {
|
||||
smtp.testDraft(draftId, adminId(principal), principal.getName());
|
||||
return "redirect:/admin/smtp";
|
||||
String test(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
|
||||
Principal principal, Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return renderActionError(model, bindingResult);
|
||||
}
|
||||
try {
|
||||
smtp.testDraft(action.getDraftId(), adminId(principal), principal.getName());
|
||||
return "redirect:/admin/smtp?tested";
|
||||
} catch (IllegalArgumentException | IllegalStateException failure) {
|
||||
bindingResult.reject("smtp.test.failed", failure.getMessage());
|
||||
return renderActionError(model, bindingResult);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/activate")
|
||||
String activate(@RequestParam long draftId, Principal principal) {
|
||||
smtp.activate(draftId, adminId(principal));
|
||||
return "redirect:/admin/smtp";
|
||||
String activate(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
|
||||
Principal principal, Model model) {
|
||||
if (bindingResult.hasErrors()) {
|
||||
return renderActionError(model, bindingResult);
|
||||
}
|
||||
try {
|
||||
smtp.activate(action.getDraftId(), adminId(principal));
|
||||
return "redirect:/admin/smtp?activated";
|
||||
} catch (IllegalArgumentException | IllegalStateException failure) {
|
||||
bindingResult.reject("smtp.activate.failed", failure.getMessage());
|
||||
return renderActionError(model, bindingResult);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/defer")
|
||||
String deferral(HttpSession session, Model model) {
|
||||
if (smtp.hasActiveConfiguration()) {
|
||||
return "redirect:/admin/smtp";
|
||||
}
|
||||
int step = deferralStep(session);
|
||||
model.addAttribute("deferralStep", step);
|
||||
model.addAttribute("deferralWarning", DEFERRAL_WARNINGS.get(step - 1));
|
||||
return "smtp/defer";
|
||||
}
|
||||
|
||||
@PostMapping("/defer/next")
|
||||
String nextDeferral(HttpSession session) {
|
||||
session.setAttribute(DEFERRAL_STEP, Math.min(5, deferralStep(session) + 1));
|
||||
return "redirect:/admin/smtp/defer";
|
||||
}
|
||||
|
||||
@PostMapping("/defer/back")
|
||||
String previousDeferral(HttpSession session) {
|
||||
session.setAttribute(DEFERRAL_STEP, Math.max(1, deferralStep(session) - 1));
|
||||
return "redirect:/admin/smtp/defer";
|
||||
}
|
||||
|
||||
@PostMapping("/defer/finish")
|
||||
String finishDeferral(HttpSession session) {
|
||||
if (deferralStep(session) != 5) {
|
||||
return "redirect:/admin/smtp/defer";
|
||||
}
|
||||
session.removeAttribute(DEFERRAL_STEP);
|
||||
return "redirect:/dashboard";
|
||||
}
|
||||
|
||||
private String renderForm(Model model, SmtpForm submittedForm) {
|
||||
var status = smtp.setupStatus();
|
||||
model.addAttribute("smtpStatus", status);
|
||||
model.addAttribute("smtpAction", new SmtpActionForm());
|
||||
if (submittedForm == null) {
|
||||
model.addAttribute("smtpForm", SmtpForm.from(status));
|
||||
}
|
||||
return "smtp/form";
|
||||
}
|
||||
|
||||
private String renderActionError(Model model, BindingResult bindingResult) {
|
||||
model.addAttribute(BindingResult.MODEL_KEY_PREFIX + "smtpAction", bindingResult);
|
||||
return renderForm(model, null);
|
||||
}
|
||||
|
||||
private static int deferralStep(HttpSession session) {
|
||||
Object value = session.getAttribute(DEFERRAL_STEP);
|
||||
return value instanceof Integer step && step >= 1 && step <= 5 ? step : 1;
|
||||
}
|
||||
|
||||
private long adminId(Principal principal) {
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.lab.labtimesheet.feature.integration.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
|
||||
/**
|
||||
* Supplies the persistent restricted-installation warning state to server-rendered views until a tested SMTP
|
||||
* configuration is active.
|
||||
*/
|
||||
@ControllerAdvice
|
||||
class SmtpWarningAdvice {
|
||||
private final SmtpConfigurationService smtp;
|
||||
|
||||
SmtpWarningAdvice(SmtpConfigurationService smtp) {
|
||||
this.smtp = smtp;
|
||||
}
|
||||
|
||||
@ModelAttribute("smtpRestricted")
|
||||
boolean smtpRestricted() {
|
||||
return !smtp.hasActiveConfiguration();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
/** Validated identifier submitted by the SMTP test and activation forms. */
|
||||
public class SmtpActionForm {
|
||||
@NotNull(message = "SMTP draft is required")
|
||||
@Positive(message = "SMTP draft is invalid")
|
||||
private Long draftId;
|
||||
|
||||
public Long getDraftId() {
|
||||
return draftId;
|
||||
}
|
||||
|
||||
public void setDraftId(Long draftId) {
|
||||
this.draftId = draftId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public class SmtpForm {
|
||||
@NotBlank(message = "Host is required")
|
||||
@Size(max = 255, message = "Host must contain at most 255 characters")
|
||||
private String host;
|
||||
|
||||
@Min(value = 1, message = "Port must be between 1 and 65535")
|
||||
@Max(value = 65535, message = "Port must be between 1 and 65535")
|
||||
private int port = 587;
|
||||
|
||||
@NotNull(message = "Security mode is required")
|
||||
private SecurityMode securityMode = SecurityMode.STARTTLS;
|
||||
|
||||
@Size(max = 320, message = "Username must contain at most 320 characters")
|
||||
private String username;
|
||||
|
||||
@Size(max = 1024, message = "Password is too long")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "From address is required")
|
||||
@Email(message = "Enter a valid email address")
|
||||
@Size(max = 320, message = "From address must contain at most 320 characters")
|
||||
private String fromAddress;
|
||||
|
||||
@NotBlank(message = "From name is required")
|
||||
@Size(max = 120, message = "From name must contain at most 120 characters")
|
||||
private String fromName;
|
||||
|
||||
/**
|
||||
* Ensures SMTP authentication is either fully configured or completely absent.
|
||||
*
|
||||
* @return {@code true} when username and password presence agree
|
||||
*/
|
||||
@AssertTrue(message = "SMTP username and password must be supplied together")
|
||||
public boolean isAuthenticationComplete() {
|
||||
return hasText(username) == hasText(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts validated browser input into the service command. The password remains request-local until the
|
||||
* service encrypts it.
|
||||
*
|
||||
* @return SMTP draft command
|
||||
*/
|
||||
public SmtpDraft toDraft() {
|
||||
return new SmtpDraft(host, port, securityMode, clean(username), emptyToNull(password), fromAddress, fromName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a safe form representation of an existing draft without decrypting or exposing its password.
|
||||
*
|
||||
* @param status current non-secret setup status
|
||||
* @return form populated only with non-secret values
|
||||
*/
|
||||
public static SmtpForm from(SmtpSetupStatus status) {
|
||||
SmtpForm form = new SmtpForm();
|
||||
if (status.draftId() != null) {
|
||||
form.host = status.host();
|
||||
form.port = status.port();
|
||||
form.securityMode = status.securityMode();
|
||||
form.username = status.username();
|
||||
form.fromAddress = status.fromAddress();
|
||||
form.fromName = status.fromName();
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
/** Clears the request-local cleartext password before rendering. */
|
||||
public void clearPassword() {
|
||||
password = null;
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return hasText(value) ? value.trim() : null;
|
||||
}
|
||||
|
||||
private static String emptyToNull(String 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; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
|
||||
/**
|
||||
* Non-secret snapshot used by Admin setup views. No encrypted or cleartext credential material crosses this
|
||||
* service boundary.
|
||||
*
|
||||
* @param active whether a tested SMTP configuration is active
|
||||
* @param draftId editable draft identifier, or {@code null} when no draft exists
|
||||
* @param tested whether the current draft most recently passed its connection test
|
||||
* @param host draft host
|
||||
* @param port draft port
|
||||
* @param securityMode draft transport security
|
||||
* @param username draft username, or {@code null}
|
||||
* @param fromAddress draft sender address
|
||||
* @param fromName draft sender display name
|
||||
*/
|
||||
public record SmtpSetupStatus(boolean active, Long draftId, boolean tested, String host, int port,
|
||||
SecurityMode securityMode, String username, String fromAddress, String fromName) {
|
||||
}
|
||||
+25
@@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
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.entity.SmtpConfiguration;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.springframework.core.env.Environment;
|
||||
@@ -78,6 +79,30 @@ public class SmtpConfigurationService {
|
||||
return mailDelivery.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the non-secret SMTP state needed by the Admin setup page.
|
||||
* Password ciphertext, nonce, and decrypted credentials are never included.
|
||||
*
|
||||
* @return current active flag and editable draft metadata
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpSetupStatus setupStatus() {
|
||||
boolean active = configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
return configurations.findByStatus(SmtpStatus.DRAFT)
|
||||
.map(draft -> new SmtpSetupStatus(
|
||||
active,
|
||||
draft.getId(),
|
||||
draft.getTestedAt() != null,
|
||||
draft.getHost(),
|
||||
draft.getPort(),
|
||||
draft.getSecurityMode(),
|
||||
draft.getUsername(),
|
||||
draft.getFromAddress(),
|
||||
draft.getFromName()))
|
||||
.orElseGet(() -> new SmtpSetupStatus(active, null, false, null, 587,
|
||||
SecurityMode.STARTTLS, null, null, null));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return mailDelivery.activeConnection();
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
<main>
|
||||
<h1>Choose your password</h1>
|
||||
<p th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form method="post" th:action="@{/activate}">
|
||||
<input name="token" type="hidden" th:value="${token}">
|
||||
<form method="post" th:action="@{/activate}" th:object="${activationForm}">
|
||||
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||
</div>
|
||||
<input th:field="*{token}" type="hidden">
|
||||
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
|
||||
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||
<label>Confirm password <input name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
|
||||
<p th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}" role="alert"></p>
|
||||
<p th:if="${#fields.hasErrors('passwordConfirmed')}" th:errors="*{passwordConfirmed}" role="alert"></p>
|
||||
<button type="submit">Activate account</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -4,25 +4,32 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create account</h1>
|
||||
<p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||
<p th:if="${param.created}" role="status">Account created and activation email sent.</p>
|
||||
<p th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
|
||||
<p th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form method="post" th:action="@{/admin/accounts}">
|
||||
<label>Email <input name="email" type="email" required autocomplete="off"></label>
|
||||
<label>Display name <input name="displayName" required autocomplete="off"></label>
|
||||
<form method="post" th:action="@{/admin/accounts}" th:object="${accountForm}">
|
||||
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||
</div>
|
||||
<label>Email <input th:field="*{email}" type="email" required autocomplete="off"></label>
|
||||
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||
<label>Display name <input th:field="*{displayName}" required autocomplete="off"></label>
|
||||
<p th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||
<label>Role
|
||||
<select name="role" required>
|
||||
<select th:field="*{role}" required>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="MENTOR">Mentor</option>
|
||||
<option value="INTERN">Intern</option>
|
||||
</select>
|
||||
</label>
|
||||
<p th:if="${#fields.hasErrors('role')}" th:errors="*{role}" role="alert"></p>
|
||||
<fieldset>
|
||||
<legend>Intern details</legend>
|
||||
<label>Student code <input name="studentCode" autocomplete="off"></label>
|
||||
<label>Internship start <input name="internshipStart" type="date"></label>
|
||||
<label>Internship end <input name="internshipEnd" type="date"></label>
|
||||
<label>Student code <input th:field="*{studentCode}" autocomplete="off"></label>
|
||||
<label>Internship start <input th:field="*{internshipStart}" type="date"></label>
|
||||
<label>Internship end <input th:field="*{internshipEnd}" type="date"></label>
|
||||
</fieldset>
|
||||
<p th:if="${#fields.hasErrors('internDetailsValid')}" th:errors="*{internDetailsValid}" role="alert"></p>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Create the first administrator</h1>
|
||||
<p th:if="${error}" th:text="${error}" role="alert"></p>
|
||||
<form method="post" th:action="@{/bootstrap}">
|
||||
<label>Email <input name="email" type="email" required autocomplete="email"></label>
|
||||
<label>Display name <input name="displayName" required autocomplete="name"></label>
|
||||
<form method="post" th:action="@{/bootstrap}" th:object="${bootstrapForm}">
|
||||
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||
</div>
|
||||
<label>Email <input th:field="*{email}" type="email" required autocomplete="email"></label>
|
||||
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||
<label>Display name <input th:field="*{displayName}" required autocomplete="name"></label>
|
||||
<p th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
|
||||
<p th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||
<button type="submit">Create administrator</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head><meta charset="utf-8"><title>Defer SMTP configuration</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Defer SMTP configuration</h1>
|
||||
<p role="alert" th:text="${deferralWarning}"></p>
|
||||
<p th:text="|Confirmation ${deferralStep} of 5|"></p>
|
||||
<a th:href="@{/admin/smtp}">Configure SMTP</a>
|
||||
<form th:if="${deferralStep > 1}" method="post" th:action="@{/admin/smtp/defer/back}">
|
||||
<button type="submit">Back</button>
|
||||
</form>
|
||||
<p th:if="${deferralStep == 1}"><a th:href="@{/admin/smtp}">Back</a></p>
|
||||
<form th:if="${deferralStep < 5}" method="post" th:action="@{/admin/smtp/defer/next}">
|
||||
<button type="submit">I understand; continue</button>
|
||||
</form>
|
||||
<form th:if="${deferralStep == 5}" method="post" th:action="@{/admin/smtp/defer/finish}">
|
||||
<button type="submit">Finish without SMTP</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,16 +4,41 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>SMTP configuration</h1>
|
||||
<form method="post" th:action="@{/admin/smtp/draft}">
|
||||
<label>Host <input name="host" required></label>
|
||||
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
|
||||
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
|
||||
<label>Username <input name="username" autocomplete="username"></label>
|
||||
<p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||
<p th:if="${param.onboarding}">Configure SMTP now to enable account onboarding and recovery.</p>
|
||||
<p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
|
||||
<p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
|
||||
<p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
|
||||
<form method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
|
||||
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||
</div>
|
||||
<label>Host <input th:field="*{host}" required></label>
|
||||
<p th:if="${#fields.hasErrors('host')}" th:errors="*{host}" role="alert"></p>
|
||||
<label>Port <input th:field="*{port}" type="number" min="1" max="65535" required></label>
|
||||
<p th:if="${#fields.hasErrors('port')}" th:errors="*{port}" role="alert"></p>
|
||||
<label>Security <select th:field="*{securityMode}"><option value="STARTTLS">STARTTLS</option><option value="TLS">TLS</option><option value="NONE">NONE</option></select></label>
|
||||
<p th:if="${#fields.hasErrors('securityMode')}" th:errors="*{securityMode}" role="alert"></p>
|
||||
<label>Username <input th:field="*{username}" autocomplete="username"></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
|
||||
<label>From address <input name="fromAddress" type="email" required></label>
|
||||
<label>From name <input name="fromName" required></label>
|
||||
<p th:if="${#fields.hasErrors('authenticationComplete')}" th:errors="*{authenticationComplete}" role="alert"></p>
|
||||
<label>From address <input th:field="*{fromAddress}" type="email" required></label>
|
||||
<p th:if="${#fields.hasErrors('fromAddress')}" th:errors="*{fromAddress}" role="alert"></p>
|
||||
<label>From name <input th:field="*{fromName}" required></label>
|
||||
<p th:if="${#fields.hasErrors('fromName')}" th:errors="*{fromName}" role="alert"></p>
|
||||
<button type="submit">Save draft</button>
|
||||
</form>
|
||||
<form th:if="${smtpStatus.draftId != null}" method="post" th:action="@{/admin/smtp/test}">
|
||||
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
|
||||
<button type="submit">Test connection</button>
|
||||
</form>
|
||||
<form th:if="${smtpStatus.draftId != null and smtpStatus.tested}" method="post" th:action="@{/admin/smtp/activate}">
|
||||
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
|
||||
<button type="submit">Activate SMTP</button>
|
||||
</form>
|
||||
<p th:if="${param.onboarding}">
|
||||
<a th:href="@{/admin/smtp/defer}">Defer SMTP</a>
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user