fix account and SMTP onboarding workflows

This commit is contained in:
sechmachine
2026-08-15 02:29:41 +07:00
parent 6181984cf8
commit 17fa25bb09
20 changed files with 988 additions and 85 deletions
@@ -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();
@@ -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";
}
}
@@ -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; }
}
@@ -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) {
@@ -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) {
}
@@ -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>
+15 -8
View File
@@ -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>
+32 -7
View File
@@ -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>