fix(ui): persist SMTP restriction in shared shell

This commit is contained in:
sechmachine
2026-08-15 04:33:37 +07:00
parent ec99586393
commit ddf688a533
10 changed files with 303 additions and 61 deletions
+2
View File
@@ -152,6 +152,8 @@
.badge-warning { color: var(--warning); }
.badge-danger { color: var(--danger); }
.alert { margin: .75rem 0; border: 1px solid var(--border); border-radius: .6rem; padding: .75rem .9rem; }
.alert-warning { border-color: color-mix(in srgb, var(--warning), transparent 55%); color: var(--warning); }
.alert-action { margin-left: .6rem; font-weight: 700; }
.alert-error { border-color: color-mix(in srgb, var(--danger), transparent 60%); color: var(--danger); }
.empty-state { padding: 2.5rem 1rem; text-align: center; }
.empty-state p { margin: .3rem auto 0; color: var(--muted); }
File diff suppressed because one or more lines are too long
@@ -10,7 +10,6 @@
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
<main>
<p class="page-description">Create a role-specific account and send its one-time activation link.</p>
<p class="alert alert-error" th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
<p class="alert" th:if="${param.created}" role="status">Account created and activation email sent.</p>
<p class="alert alert-error" th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}" th:object="${accountForm}">
@@ -52,6 +52,11 @@
<div class="page-heading-copy"><h1 class="page-title" th:text="${pageTitle}">Page</h1></div>
<div class="primary-action" th:if="${primaryAction != null}" th:replace="${primaryAction}"></div>
</div>
<div class="alert alert-warning" role="alert" sec:authorize="hasRole('ADMIN')"
th:if="${smtpRestricted}">
<strong>This installation remains restricted until tested SMTP is active.</strong>
<a class="alert-action" th:href="@{/admin/smtp}">Configure SMTP</a>
</div>
<th:block th:replace="${content}"></th:block>
</div>
</div>
+25 -16
View File
@@ -1,22 +1,31 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Defer SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/layout :: shell(
pageTitle='Defer SMTP configuration',
section='Admin',
activeNav='smtp',
primaryAction=~{::#primary-action},
content=~{::main})}">
<body>
<a id="primary-action" class="button button-primary" th:href="@{/admin/smtp}">Configure SMTP</a>
<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>
<p class="page-description">Review each consequence before continuing without an active mail service.</p>
<section class="panel form-panel form-grid" aria-labelledby="smtp-deferral-progress">
<p class="alert alert-warning" role="alert" th:text="${deferralWarning}"></p>
<p id="smtp-deferral-progress" th:text="|Confirmation ${deferralStep} of 5|"></p>
<div class="form-actions">
<form th:if="${deferralStep > 1}" method="post" th:action="@{/admin/smtp/defer/back}">
<button class="button" type="submit">Back</button>
</form>
<a class="button" th:if="${deferralStep == 1}" th:href="@{/admin/smtp}">Back</a>
<form th:if="${deferralStep < 5}" method="post" th:action="@{/admin/smtp/defer/next}">
<button class="button button-primary" type="submit">I understand; continue</button>
</form>
<form th:if="${deferralStep == 5}" method="post" th:action="@{/admin/smtp/defer/finish}">
<button class="button button-primary" type="submit">Finish without SMTP</button>
</form>
</div>
</section>
</main>
</body>
</html>
+81 -37
View File
@@ -1,45 +1,89 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/layout :: shell(
pageTitle='SMTP configuration',
section='Admin',
activeNav='smtp',
primaryAction=~{::#primary-action},
content=~{::main})}">
<body>
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
<main>
<h1>SMTP configuration</h1>
<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>
<p th:if="${smtpActionError}" th:text="${smtpActionError}" role="alert"></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>
<p class="page-description" th:if="${param.onboarding}">Configure SMTP now to enable account onboarding and recovery.</p>
<p class="alert" th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
<p class="alert" th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
<p class="alert" th:if="${smtpStatus.tested}" role="status">Test passed.</p>
<p class="alert alert-error" th:if="${smtpActionError}" th:text="${smtpActionError}" role="alert"></p>
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
<div id="smtp-form-error-summary" class="alert alert-error" role="alert"
aria-labelledby="smtp-form-error-summary-title" th:if="${#fields.hasAnyErrors()}">
<strong id="smtp-form-error-summary-title">Please correct the highlighted SMTP settings.</strong>
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}"></li></ul>
</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>
<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>
<div class="form-grid form-grid-three">
<div class="field">
<label class="field-label" for="smtp-host">Host</label>
<input class="control" id="smtp-host" th:field="*{host}" required
th:attr="aria-invalid=${#fields.hasErrors('host')},aria-describedby=${#fields.hasErrors('host') ? 'smtp-host-error' : null}">
<p class="field-error" id="smtp-host-error" th:if="${#fields.hasErrors('host')}" th:errors="*{host}" role="alert"></p>
</div>
<div class="field">
<label class="field-label" for="smtp-port">Port</label>
<input class="control" id="smtp-port" th:field="*{port}" type="number" min="1" max="65535" required
th:attr="aria-invalid=${#fields.hasErrors('port')},aria-describedby=${#fields.hasErrors('port') ? 'smtp-port-error' : null}">
<p class="field-error" id="smtp-port-error" th:if="${#fields.hasErrors('port')}" th:errors="*{port}" role="alert"></p>
</div>
<div class="field">
<label class="field-label" for="smtp-security-mode">Security</label>
<select class="control" id="smtp-security-mode" th:field="*{securityMode}"
th:attr="aria-invalid=${#fields.hasErrors('securityMode')},aria-describedby=${#fields.hasErrors('securityMode') ? 'smtp-security-mode-error' : null}">
<option value="STARTTLS">STARTTLS</option>
<option value="TLS">TLS</option>
<option value="NONE">NONE</option>
</select>
<p class="field-error" id="smtp-security-mode-error" th:if="${#fields.hasErrors('securityMode')}" th:errors="*{securityMode}" role="alert"></p>
</div>
</div>
<div class="form-grid form-grid-three">
<div class="field">
<label class="field-label" for="smtp-username">Username</label>
<input class="control" id="smtp-username" th:field="*{username}" autocomplete="username"
th:attr="aria-invalid=${#fields.hasErrors('authenticationComplete')},aria-describedby=${#fields.hasErrors('authenticationComplete') ? 'smtp-authentication-error' : null}">
</div>
<div class="field">
<label class="field-label" for="smtp-password">Password</label>
<input class="control" id="smtp-password" name="password" type="password" autocomplete="new-password"
th:attr="aria-invalid=${#fields.hasErrors('authenticationComplete')},aria-describedby=${#fields.hasErrors('authenticationComplete') ? 'smtp-authentication-error' : null}">
<p class="field-error" id="smtp-authentication-error" th:if="${#fields.hasErrors('authenticationComplete')}" th:errors="*{authenticationComplete}" role="alert"></p>
</div>
<div class="field">
<label class="field-label" for="smtp-from-address">From address</label>
<input class="control" id="smtp-from-address" th:field="*{fromAddress}" type="email" required
th:attr="aria-invalid=${#fields.hasErrors('fromAddress')},aria-describedby=${#fields.hasErrors('fromAddress') ? 'smtp-from-address-error' : null}">
<p class="field-error" id="smtp-from-address-error" th:if="${#fields.hasErrors('fromAddress')}" th:errors="*{fromAddress}" role="alert"></p>
</div>
</div>
<div class="field">
<label class="field-label" for="smtp-from-name">From name</label>
<input class="control" id="smtp-from-name" th:field="*{fromName}" required
th:attr="aria-invalid=${#fields.hasErrors('fromName')},aria-describedby=${#fields.hasErrors('fromName') ? 'smtp-from-name-error' : null}">
<p class="field-error" id="smtp-from-name-error" th:if="${#fields.hasErrors('fromName')}" th:errors="*{fromName}" role="alert"></p>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Save draft</button></div>
</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>
<div class="inline-actions">
<form th:if="${smtpStatus.draftId != null}" method="post" th:action="@{/admin/smtp/test}">
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
<button class="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 class="button button-primary" type="submit">Activate SMTP</button>
</form>
<a class="button" th:if="${param.onboarding}" th:href="@{/admin/smtp/defer}">Defer SMTP</a>
</div>
</main>
</body>
</html>
@@ -71,6 +71,8 @@ class BootstrapOnboardingWebIntegrationTest {
.with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(view().name("smtp/defer"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("/assets/theme.js")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("data-sidebar-toggle")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Account onboarding is disabled")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Back")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Configure SMTP")))
@@ -91,6 +93,20 @@ class BootstrapOnboardingWebIntegrationTest {
.with(csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/dashboard"));
mockMvc.perform(get("/dashboard")
.with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
mockMvc.perform(get("/admin/accounts/new")
.with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
}
@Test
@@ -102,6 +102,11 @@ class SmtpOnboardingWebIntegrationTest {
.andExpect(content().string(org.hamcrest.Matchers.containsString("SMTP is active")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("restricted installation"))));
mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("installation remains restricted"))));
}
@Test
@@ -111,16 +116,71 @@ class SmtpOnboardingWebIntegrationTest {
.with(csrf())
.param("host", "")
.param("port", "70000")
.param("securityMode", "STARTTLS")
.param("securityMode", "")
.param("username", "smtp-user")
.param("password", "must-not-be-rendered")
.param("password", "")
.param("fromAddress", "not-an-email")
.param("fromName", "Safe sender name"))
.param("fromName", ""))
.andExpect(status().isOk())
.andExpect(view().name("smtp/form"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("/assets/theme.js")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-form-error-summary\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-labelledby=\"smtp-form-error-summary-title\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-host\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-host-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-host-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-port\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-port-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-port-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-security-mode\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-security-mode-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"id=\"smtp-security-mode-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-username\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-password\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-authentication-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"id=\"smtp-username\" autocomplete=\"username\" aria-invalid=\"true\""
+ " aria-describedby=\"smtp-authentication-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"id=\"smtp-authentication-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-from-address\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-from-address-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"id=\"smtp-from-address-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("id=\"smtp-from-name\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"aria-describedby=\"smtp-from-name-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"id=\"smtp-from-name-error\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Host is required")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Port must be between")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("valid email address")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("value=\"smtp-secret\""))));
}
@Test
void invalidDraftRetainsSafeValuesButNeverTheSubmittedPassword() throws Exception {
mockMvc.perform(post("/admin/smtp/draft")
.with(user("admin@example.com").roles("ADMIN"))
.with(csrf())
.param("host", "")
.param("port", "1025")
.param("securityMode", "NONE")
.param("username", "safe-smtp-user")
.param("password", "must-not-be-rendered")
.param("fromAddress", "notifications@example.com")
.param("fromName", "Safe sender name"))
.andExpect(status().isOk())
.andExpect(view().name("smtp/form"))
.andExpect(content().string(org.hamcrest.Matchers.containsString("safe-smtp-user")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Safe sender name")))
.andExpect(content().string(org.hamcrest.Matchers.not(
org.hamcrest.Matchers.containsString("must-not-be-rendered"))));
@@ -130,7 +190,8 @@ class SmtpOnboardingWebIntegrationTest {
void restrictedWarningPersistsOnAdminPagesUntilActivationAndMutationsRequireCsrf() throws Exception {
mockMvc.perform(get("/admin/accounts/new").with(user("admin@example.com").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("restricted installation")));
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"installation remains restricted")));
mockMvc.perform(post("/admin/smtp/draft")
.with(user("admin@example.com").roles("ADMIN"))
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
@@ -35,15 +36,31 @@ class DashboardControllerWebTest {
void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Admin(2, 1, 1, 3);
given(dashboards.admin("admin@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(false);
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/admin"))
.andExpect(model().attribute("dashboard", dashboard));
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
verify(dashboards).admin("admin@example.test");
}
@Test
void activeSmtpKeepsAdminDashboardFreeOfTheRestrictedInstallationWarning() throws Exception {
var dashboard = new DashboardView.Admin(2, 1, 1, 3);
given(dashboards.admin("admin@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
}
@Test
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
@@ -52,7 +69,9 @@ class DashboardControllerWebTest {
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/mentor"))
.andExpect(model().attribute("dashboard", dashboard));
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
verify(dashboards).mentor("mentor@example.test");
}