fix(ui): resolve round one shell findings
This commit is contained in:
+20
@@ -7,15 +7,35 @@ import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
/**
|
||||
* Selects the dashboard view for the authenticated global authority.
|
||||
*
|
||||
* <p>The authority selects only which role-specific flow to invoke. The reporting service then
|
||||
* reloads and revalidates the persisted account role and lifecycle before returning any data.
|
||||
*/
|
||||
@Controller
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
|
||||
/**
|
||||
* Creates the dashboard endpoint backed by the reporting composition service.
|
||||
*
|
||||
* @param dashboardService service that authorizes and assembles role-scoped dashboard data
|
||||
*/
|
||||
public DashboardController(DashboardService dashboardService) {
|
||||
this.dashboardService = dashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dashboard permitted by the caller's authenticated global role.
|
||||
*
|
||||
* @param authentication authenticated caller whose name is the persisted account email
|
||||
* @param model Thymeleaf model populated with the role-specific {@code dashboard} projection
|
||||
* @return the Admin, Mentor, or Intern dashboard template name
|
||||
* @throws DashboardAccessDeniedException when the authority is unsupported or does not match
|
||||
* an active persisted account identity
|
||||
*/
|
||||
@GetMapping("/dashboard")
|
||||
public String dashboard(Authentication authentication, Model model) {
|
||||
String email = authentication.getName();
|
||||
|
||||
+10
@@ -4,9 +4,19 @@ import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
/**
|
||||
* Non-disclosing denial raised when an authenticated identity cannot access a role dashboard.
|
||||
*
|
||||
* <p>Callers must not include protected record identifiers or lifecycle details in the message.
|
||||
*/
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class DashboardAccessDeniedException extends AccessDeniedException {
|
||||
|
||||
/**
|
||||
* Creates a safe dashboard denial.
|
||||
*
|
||||
* @param message generic reason suitable for server-side diagnosis without protected details
|
||||
*/
|
||||
public DashboardAccessDeniedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,62 @@ package com.lab.labtimesheet.feature.reporting.model.dto;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Closed set of immutable, role-specific dashboard projections rendered by Reporting.
|
||||
*
|
||||
* <p>Each projection contains only data authorized and calculated by its owning feature service.
|
||||
*/
|
||||
public sealed interface DashboardView {
|
||||
|
||||
/**
|
||||
* System-wide counts visible to an active Admin.
|
||||
*
|
||||
* @param activeAccounts active account count
|
||||
* @param pendingActivations accounts awaiting activation
|
||||
* @param activeInternships active internship count
|
||||
* @param activeProjects active Projects visible to an Admin
|
||||
*/
|
||||
record Admin(long activeAccounts, long pendingActivations,
|
||||
long activeInternships, long activeProjects) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owning-Mentor operational summary; empty authorized scopes are represented by zero counts.
|
||||
*
|
||||
* @param displayName persisted Mentor display name
|
||||
* @param activeProjects active owned Project count
|
||||
* @param activeMembers distinct eligible active members across owned Projects
|
||||
* @param blockedTasks blocked Tasks visible within active owned Projects
|
||||
*/
|
||||
record Mentor(String displayName, long activeProjects, long activeMembers,
|
||||
long blockedTasks) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Eligible Intern summary for the Attendance policy's current business date.
|
||||
*
|
||||
* @param displayName persisted Intern display name
|
||||
* @param attendanceState current policy-local attendance state
|
||||
* @param activeProjects active Projects containing a current eligible membership
|
||||
* @param assignedTasks current assigned Task count
|
||||
* @param priorityTasks ordered Task-owned priority items; empty when none are assigned
|
||||
*/
|
||||
record Intern(String displayName, AttendanceState attendanceState, long activeProjects,
|
||||
long assignedTasks, List<AssignedTask> priorityTasks) implements DashboardView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Task row shown on an Intern dashboard.
|
||||
*
|
||||
* @param title Task title
|
||||
* @param projectName owning Project name
|
||||
* @param status Task status label supplied by the Task feature
|
||||
* @param dueDate Task due date, or {@code null} when no due date is assigned
|
||||
*/
|
||||
record AssignedTask(String title, String projectName, String status, LocalDate dueDate) {
|
||||
}
|
||||
|
||||
/** Current attendance state exposed to the Intern dashboard. */
|
||||
enum AttendanceState {
|
||||
NOT_CHECKED_IN("Not checked in"),
|
||||
CHECKED_IN("Checked in"),
|
||||
@@ -31,6 +70,11 @@ public sealed interface DashboardView {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the English presentation label for this state.
|
||||
*
|
||||
* @return non-empty user-facing state label
|
||||
*/
|
||||
public String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Composes authorized dashboard projections exclusively from public feature services and DTOs.
|
||||
*
|
||||
* <p>This service owns no persistence mapping or business-date calculation. Account lifecycle and
|
||||
* role are revalidated from persisted identity data, while Project, Task, and Attendance retain
|
||||
* ownership of their query scope, ordering, and attendance-policy business date.
|
||||
*/
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
public class DashboardService {
|
||||
@@ -28,6 +35,14 @@ public class DashboardService {
|
||||
private final TaskDashboardService tasks;
|
||||
private final AttendanceApplicationService attendance;
|
||||
|
||||
/**
|
||||
* Creates a reporting coordinator over the concrete feature query boundaries.
|
||||
*
|
||||
* @param accounts account identity and Admin summary boundary
|
||||
* @param projects role-scoped Project summary boundary
|
||||
* @param tasks role-scoped Task dashboard boundary
|
||||
* @param attendance attendance state boundary using the active policy business date
|
||||
*/
|
||||
public DashboardService(
|
||||
AccountService accounts,
|
||||
ProjectQueryService projects,
|
||||
@@ -39,6 +54,16 @@ public class DashboardService {
|
||||
this.attendance = attendance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds system-wide Admin counts after confirming an active persisted Admin identity.
|
||||
*
|
||||
* <p>Counts are zero when the corresponding feature has no matching records.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return account lifecycle counts and the Admin-visible active Project count
|
||||
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
|
||||
* not an Admin
|
||||
*/
|
||||
public DashboardView.Admin admin(String email) {
|
||||
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
|
||||
AccountSummary accountSummary = accounts.summary();
|
||||
@@ -50,6 +75,17 @@ public class DashboardService {
|
||||
projectSummary.activeProjectCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the owning-Mentor dashboard after persisted-role revalidation.
|
||||
*
|
||||
* <p>Project and member counts are scoped by the Project service; blocked Task count is scoped
|
||||
* by the Task service. Each empty scope is represented by a zero count.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return Mentor display name and role-scoped Project, member, and blocked-Task counts
|
||||
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
|
||||
* not a Mentor
|
||||
*/
|
||||
public DashboardView.Mentor mentor(String email) {
|
||||
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
|
||||
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
|
||||
@@ -61,6 +97,19 @@ public class DashboardService {
|
||||
taskSummary.blockedTaskCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the eligible Intern dashboard after persisted-role revalidation.
|
||||
*
|
||||
* <p>The Attendance feature determines today's state from its policy-owned business date. The
|
||||
* Task feature owns assigned count and priority ordering; no matching Tasks produce an empty
|
||||
* priority list. Attendance ineligibility is converted to the same non-disclosing dashboard
|
||||
* denial as other invalid Intern lifecycle states.
|
||||
*
|
||||
* @param email authenticated account email
|
||||
* @return Intern attendance state, scoped counts, and at most the Task service's priority items
|
||||
* @throws DashboardAccessDeniedException when the persisted account or internship is not
|
||||
* eligible for the Intern dashboard
|
||||
*/
|
||||
public DashboardView.Intern intern(String email) {
|
||||
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
|
||||
AttendanceCurrentState attendanceState;
|
||||
|
||||
@@ -39,12 +39,17 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="item : ${items}">
|
||||
<td th:text="${item.workDate}"></td>
|
||||
<td th:text="${item.checkInAt}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
|
||||
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
|
||||
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
|
||||
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
|
||||
<td th:text="|${item.policy.scheduledStart}–${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
|
||||
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
|
||||
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
|
||||
<td>
|
||||
<span th:if="${!item.violations.late and !item.violations.earlyDeparture and !item.violations.missingCheckout}">On time</span>
|
||||
<span th:if="${item.violations.late}">Late</span>
|
||||
<span th:if="${item.violations.earlyDeparture}" th:text="${item.violations.late ? ', Early departure' : 'Early departure'}">Early departure</span>
|
||||
<span th:if="${item.violations.missingCheckout}" th:text="${item.violations.late or item.violations.earlyDeparture ? ', Missing checkout' : 'Missing checkout'}">Missing checkout</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${errorTitle},
|
||||
section='Error',
|
||||
activeNav='',
|
||||
primaryAction=~{},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<main>
|
||||
<section class="panel empty-state">
|
||||
<p class="auth-eyebrow" th:text="|Error ${errorStatus}|">Error</p>
|
||||
<h2 th:text="${errorTitle}">Request could not be completed</h2>
|
||||
<p th:text="${errorMessage}">The requested operation could not be completed.</p>
|
||||
<p><a class="button button-primary" th:href="@{/dashboard}">Return to dashboard</a></p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,8 +24,7 @@
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
|
||||
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
|
||||
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -38,7 +37,7 @@
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<a class="account" th:href="@{/profile}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Profile</span></a>
|
||||
<div class="account"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Account</span></div>
|
||||
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -46,7 +45,6 @@
|
||||
<header class="app-header">
|
||||
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
|
||||
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
|
||||
<div class="header-actions"><a class="icon-button" th:href="@{/notifications}" aria-label="Notifications" title="Notifications"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#bell}"></use></svg></a></div>
|
||||
</header>
|
||||
<div class="page">
|
||||
<div class="page-heading">
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="name">Name</label>
|
||||
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')}">
|
||||
<p class="field-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
|
||||
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')},aria-describedby=${#fields.hasErrors('name') ? 'name-error' : null}">
|
||||
<p class="field-error" id="name-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="description">Description</label>
|
||||
<textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
|
||||
</div>
|
||||
<div class="form-grid form-grid-three">
|
||||
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
|
||||
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
|
||||
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
|
||||
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="leader">Initial Leader user ID</label>
|
||||
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')}">
|
||||
<p class="field-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
||||
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
|
||||
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<p class="page-description">Assign work to a current eligible Project member.</p>
|
||||
<form class="panel form-panel form-grid" method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
|
||||
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Please correct the highlighted fields.</strong><ul><li th:each="error : ${#fields.allErrors()}" th:text="${error}">Validation error</li></ul></div>
|
||||
<div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div>
|
||||
<div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')},aria-describedby=${#fields.hasErrors('title') ? 'title-error' : null}"><p class="field-error" id="title-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div>
|
||||
<div class="field"><label class="field-label" for="description">Description</label><textarea class="control" id="description" rows="5" th:field="*{description}"></textarea></div>
|
||||
<div class="form-grid form-grid-three">
|
||||
<div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div>
|
||||
<div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></div>
|
||||
<div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')},aria-describedby=${#fields.hasErrors('assigneeMembershipId') ? 'assigneeMembershipId-error' : null}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" id="assigneeMembershipId-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div>
|
||||
<div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')},aria-describedby=${#fields.hasErrors('dueDate') ? 'dueDate-error' : null}"><p class="field-error" id="dueDate-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></div>
|
||||
</div>
|
||||
<div class="form-actions"><a class="button" th:href="@{/projects/{projectId}/tasks(projectId=${projectId})}">Cancel</a><button class="button button-primary" type="submit">Create Task</button></div>
|
||||
</form>
|
||||
|
||||
+39
@@ -6,6 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest {
|
||||
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation() throws Exception {
|
||||
mvc.perform(get("/template-contract/attendance/history/populated")
|
||||
.with(user("intern@example.test").roles("INTERN")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("14/08/2026")))
|
||||
.andExpect(content().string(containsString("09:05")))
|
||||
.andExpect(content().string(containsString("16:00")))
|
||||
.andExpect(content().string(containsString("08:30–15:30 (Asia/Ho_Chi_Minh)")))
|
||||
.andExpect(content().string(containsString("Late")))
|
||||
.andExpect(content().string(containsString("Early departure")))
|
||||
.andExpect(content().string(containsString("Missing checkout")));
|
||||
}
|
||||
|
||||
@Controller
|
||||
public static class TemplateController {
|
||||
|
||||
@@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest {
|
||||
return "attendance/history";
|
||||
}
|
||||
|
||||
@GetMapping("/template-contract/attendance/history/populated")
|
||||
String populatedHistory(Model model) {
|
||||
model.addAttribute("ownHistory", true);
|
||||
model.addAttribute("from", LocalDate.of(2026, 8, 1));
|
||||
model.addAttribute("to", LocalDate.of(2026, 8, 31));
|
||||
model.addAttribute("items", List.of(
|
||||
new AttendanceHistoryItem(
|
||||
LocalDate.of(2026, 8, 14),
|
||||
Instant.parse("2026-08-14T02:05:00Z"),
|
||||
Instant.parse("2026-08-14T09:00:00Z"),
|
||||
AttendancePolicy.seeded(1L),
|
||||
new AttendanceViolations(true, true, false)),
|
||||
new AttendanceHistoryItem(
|
||||
LocalDate.of(2026, 8, 13),
|
||||
Instant.parse("2026-08-13T01:30:00Z"),
|
||||
null,
|
||||
AttendancePolicy.seeded(1L),
|
||||
new AttendanceViolations(true, false, true))));
|
||||
return "attendance/history";
|
||||
}
|
||||
|
||||
@GetMapping("/template-contract/attendance/calendar")
|
||||
String calendar(Model model) {
|
||||
model.addAttribute("today", LocalDate.of(2026, 8, 15));
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.lab.labtimesheet.feature.reporting.controller;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.controller.ProjectController;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import com.lab.labtimesheet.feature.task.controller.TaskController;
|
||||
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest({ProjectController.class, TaskController.class})
|
||||
class ProjectTaskFormAccessibilityWebTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectQueryService projectQueries;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectService projects;
|
||||
|
||||
@MockitoBean
|
||||
private TaskService tasks;
|
||||
|
||||
@Test
|
||||
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
|
||||
mvc.perform(post("/projects")
|
||||
.with(user("mentor@example.test").roles("MENTOR"))
|
||||
.with(csrf())
|
||||
.param("name", " ")
|
||||
.param("startDate", "2026-08-01")
|
||||
.param("endDate", "")
|
||||
.param("initialLeaderUserId", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("aria-describedby=\"name-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"name-error\"")))
|
||||
.andExpect(content().string(containsString("aria-describedby=\"endDate-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"endDate-error\"")))
|
||||
.andExpect(content().string(containsString("aria-describedby=\"initialLeaderUserId-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"initialLeaderUserId-error\"")));
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(user("mentor@example.test").roles("MENTOR"))
|
||||
.with(csrf())
|
||||
.param("name", "Project")
|
||||
.param("startDate", "")
|
||||
.param("endDate", "2026-08-31")
|
||||
.param("initialLeaderUserId", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("aria-describedby=\"startDate-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"startDate-error\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectDateRangeErrorIsAssociatedWithEndDate() throws Exception {
|
||||
mvc.perform(post("/projects")
|
||||
.with(user("mentor@example.test").roles("MENTOR"))
|
||||
.with(csrf())
|
||||
.param("name", "Project")
|
||||
.param("startDate", "2026-08-31")
|
||||
.param("endDate", "2026-08-01")
|
||||
.param("initialLeaderUserId", "7"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("aria-describedby=\"dateRangeValid-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"dateRangeValid-error\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskFieldErrorsHaveStableIdsAndControlAssociations() throws Exception {
|
||||
given(tasks.assignmentChoices("leader@example.test", 10L))
|
||||
.willReturn(List.of(new TaskAssigneeChoice(7L, "Member")));
|
||||
|
||||
mvc.perform(post("/projects/10/tasks")
|
||||
.with(user("leader@example.test").roles("INTERN"))
|
||||
.with(csrf())
|
||||
.param("title", " ")
|
||||
.param("dueDate", "2026-08-20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("aria-describedby=\"title-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"title-error\"")))
|
||||
.andExpect(content().string(containsString("aria-describedby=\"assigneeMembershipId-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"assigneeMembershipId-error\"")));
|
||||
|
||||
mvc.perform(post("/projects/10/tasks")
|
||||
.with(user("leader@example.test").roles("INTERN"))
|
||||
.with(csrf())
|
||||
.param("title", "Task")
|
||||
.param("assigneeMembershipId", "7")
|
||||
.param("dueDate", "invalid"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("aria-describedby=\"dueDate-error\"")))
|
||||
.andExpect(content().string(containsString("id=\"dueDate-error\"")));
|
||||
}
|
||||
}
|
||||
+29
@@ -25,7 +25,11 @@ import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
class RoleDashboardWebIntegrationTest {
|
||||
|
||||
private static final Pattern NAVIGATION_LINK = Pattern.compile(
|
||||
"<a class=\"(?:brand|nav-link|account|icon-button)\" href=\"([^\"]+)\"");
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@@ -128,6 +135,28 @@ class RoleDashboardWebIntegrationTest {
|
||||
.andExpect(content().string(containsString("Resolve accessibility review")))
|
||||
.andExpect(content().string(containsString("BLOCKED")))
|
||||
.andExpect(content().string(containsString("20/08/2026")));
|
||||
|
||||
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
|
||||
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
|
||||
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
|
||||
}
|
||||
|
||||
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
|
||||
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
Matcher matcher = NAVIGATION_LINK.matcher(dashboard);
|
||||
Set<String> paths = new LinkedHashSet<>();
|
||||
while (matcher.find()) {
|
||||
paths.add(matcher.group(1));
|
||||
}
|
||||
assertThat(paths).isNotEmpty();
|
||||
for (String path : paths) {
|
||||
mvc.perform(get(path).with(user(email).roles(role)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
private long initializeAdminAndSmtp() {
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.lab.labtimesheet.feature.reporting.controller;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@WebMvcTest(SharedErrorTemplateWebTest.ErrorTemplateController.class)
|
||||
@Import(SharedErrorTemplateWebTest.ErrorTemplateController.class)
|
||||
class SharedErrorTemplateWebTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "intern@example.test", roles = "INTERN")
|
||||
void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception {
|
||||
mvc.perform(get("/template-contract/error/404"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().string(containsString("class=\"app-shell\"")))
|
||||
.andExpect(content().string(containsString("Page not found")))
|
||||
.andExpect(content().string(not(containsString("secret Project"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
||||
void conflictPageUsesSharedShellWithoutRenderingExceptionDetails() throws Exception {
|
||||
mvc.perform(get("/template-contract/error/409"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().string(containsString("class=\"app-shell\"")))
|
||||
.andExpect(content().string(containsString("Request could not be completed")))
|
||||
.andExpect(content().string(not(containsString("internal lifecycle detail"))));
|
||||
}
|
||||
|
||||
@Controller
|
||||
static class ErrorTemplateController {
|
||||
|
||||
@GetMapping("/template-contract/error/404")
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
String notFound(Model model) {
|
||||
model.addAttribute("errorStatus", 404);
|
||||
model.addAttribute("errorTitle", "Page not found");
|
||||
model.addAttribute("errorMessage", "The requested resource is unavailable or you may not have access.");
|
||||
return "error/generic";
|
||||
}
|
||||
|
||||
@GetMapping("/template-contract/error/409")
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
String conflict(Model model) {
|
||||
model.addAttribute("errorStatus", 409);
|
||||
model.addAttribute("errorTitle", "Request could not be completed");
|
||||
model.addAttribute("errorMessage", "The request conflicts with its current state. Review and try again.");
|
||||
return "error/generic";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class UiContractWebTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
||||
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
|
||||
void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception {
|
||||
MvcResult result = mvc.perform(get("/ui-contract"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
@@ -44,10 +44,29 @@ class UiContractWebTest {
|
||||
assertTrue(html.contains("Logout"));
|
||||
assertFalse(html.contains("Accounts"));
|
||||
assertFalse(html.contains("My attendance"));
|
||||
assertFalse(html.contains("Intern attendance"));
|
||||
assertFalse(html.contains("href=\"/attendance\""));
|
||||
assertFalse(html.contains("href=\"/profile\""));
|
||||
assertFalse(html.contains("href=\"/notifications\""));
|
||||
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
|
||||
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "intern@example.test", roles = "INTERN")
|
||||
void internShellLinksToTheReachableOwnAttendanceRoute() throws Exception {
|
||||
String html = mvc.perform(get("/ui-contract"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(html.contains("href=\"/attendance\""));
|
||||
assertFalse(html.contains("href=\"/attendance/me\""));
|
||||
assertFalse(html.contains("href=\"/profile\""));
|
||||
assertFalse(html.contains("href=\"/notifications\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
|
||||
ClassPathResource css = new ClassPathResource("static/assets/app.css");
|
||||
|
||||
Reference in New Issue
Block a user