fix(ui): resolve round one shell findings

This commit is contained in:
sechmachine
2026-08-15 02:17:01 +07:00
parent c5c143c3cd
commit 8388b4cc9b
18 changed files with 526 additions and 24 deletions
@@ -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();
@@ -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>
+3 -3
View File
@@ -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>