feat(ui): integrate account pages with shared shell

This commit is contained in:
sechmachine
2026-08-15 00:53:45 +07:00
parent 69b81ddc71
commit 7dd61b9dd4
9 changed files with 236 additions and 28 deletions
+14
View File
@@ -64,6 +64,13 @@
@layer components {
.app-shell { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; }
.auth-shell { min-height: 100vh; }
.auth-header { display: flex; min-height: 4rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding: .75rem 1.25rem; }
.auth-theme { width: 9rem; }
.auth-main { display: grid; min-height: calc(100vh - 4rem); place-items: center; padding: 2rem; }
.auth-card { width: min(100%, 28rem); border: 1px solid var(--border); border-radius: .85rem; background: var(--panel); padding: 1.5rem; box-shadow: 0 16px 42px rgb(20 25 35 / .08); }
.auth-eyebrow { margin: 0 0 .35rem; color: var(--muted); font-size: .72rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.auth-form { margin-top: 1.25rem; }
[data-sidebar-collapsed="true"] .app-shell { grid-template-columns: 4rem minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); padding: 1rem .75rem; }
.brand, .account { display: flex; align-items: center; gap: .7rem; min-width: 0; padding: .25rem .4rem; }
@@ -104,6 +111,13 @@
.metric-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.metric-detail { margin-top: .18rem; color: var(--muted); font-size: .78rem; }
.field { display: grid; gap: .35rem; }
.form-panel { margin-top: 1rem; padding: 1rem; }
.form-grid { display: grid; gap: 1rem; }
.form-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.form-section { border: 1px solid var(--border); border-radius: .65rem; padding: 1rem; }
.form-section legend { padding: 0 .35rem; font-weight: 700; }
.field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; }
.form-actions { display: flex; justify-content: flex-end; gap: .6rem; }
.field-label { font-size: .78rem; font-weight: 650; }
.control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; }
.control[aria-invalid="true"] { border-color: var(--danger); }
File diff suppressed because one or more lines are too long
@@ -1,15 +1,18 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Activate account</title></head>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/auth-layout :: shell(
pageTitle='Choose your password',
eyebrow='Account activation',
content=~{::main})}">
<body>
<main>
<h1>Choose your password</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p>
<form method="post" th:action="@{/activate}">
<p class="page-description">Use at least 12 characters. This activation link can be used once.</p>
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
<form class="form-grid auth-form" method="post" th:action="@{/activate}">
<input name="token" type="hidden" th:value="${token}">
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<label>Confirm password <input name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<button type="submit">Activate account</button>
<div class="field"><label class="field-label" for="password">Password</label><input class="control" id="password" name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
<div class="field"><label class="field-label" for="confirmPassword">Confirm password</label><input class="control" id="confirmPassword" name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
<button class="button button-primary" type="submit">Activate account</button>
</form>
</main>
</body>
+26 -17
View File
@@ -1,29 +1,38 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Create account</title></head>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/layout :: shell(
pageTitle='Create account',
section='Admin',
activeNav='accounts',
primaryAction=~{::#primary-action},
content=~{::main})}">
<body>
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
<main>
<h1>Create account</h1>
<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>
<label>Role
<select name="role" required>
<p class="page-description">Create a role-specific account and send its one-time activation link.</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>
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}">
<div class="field"><label class="field-label" for="email">Email</label><input class="control" id="email" name="email" type="email" required autocomplete="off"></div>
<div class="field"><label class="field-label" for="displayName">Display name</label><input class="control" id="displayName" name="displayName" required autocomplete="off"></div>
<div class="field"><label class="field-label" for="role">Role</label>
<select class="control" id="role" name="role" required>
<option value="ADMIN">Admin</option>
<option value="MENTOR">Mentor</option>
<option value="INTERN">Intern</option>
</select>
</label>
<fieldset>
</div>
<fieldset class="form-section">
<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>
<p class="field-help">Required only when the selected role is Intern.</p>
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="studentCode">Student code</label><input class="control" id="studentCode" name="studentCode" autocomplete="off"></div>
<div class="field"><label class="field-label" for="internshipStart">Internship start</label><input class="control" id="internshipStart" name="internshipStart" type="date"></div>
<div class="field"><label class="field-label" for="internshipEnd">Internship end</label><input class="control" id="internshipEnd" name="internshipEnd" type="date"></div>
</div>
</fieldset>
<button type="submit">Create account</button>
<div class="form-actions"><a class="button" th:href="@{/dashboard}">Cancel</a><button class="button button-primary" type="submit">Create account</button></div>
</form>
</main>
</body>
@@ -7,7 +7,7 @@
primaryAction=~{::#primary-action},
content=~{::main})}">
<body>
<a id="primary-action" class="button button-primary" th:href="@{/admin/users/new}">Create account</a>
<a id="primary-action" class="button button-primary" th:href="@{/admin/accounts/new}">Create account</a>
<main>
<p class="page-description">Account readiness, internship activity, and active Project work.</p>
<section class="panel metric-strip" aria-label="System metrics">
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:fragment="shell(pageTitle, eyebrow, content)">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
<script th:src="@{/assets/theme.js}"></script>
<link rel="stylesheet" th:href="@{/assets/app.css}">
<script defer th:src="@{/assets/app.js}"></script>
</head>
<body>
<div class="auth-shell">
<header class="auth-header">
<a class="brand" th:href="@{/}">
<span class="brand-mark" aria-hidden="true">LT</span>
<strong>Lab Timesheet</strong>
</a>
<div class="theme-field auth-theme">
<label class="sr-only" for="theme-preference">Theme</label>
<select id="theme-preference" data-theme-select aria-label="Theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
</header>
<main class="auth-main">
<section class="auth-card">
<p class="auth-eyebrow" th:text="${eyebrow}">Account access</p>
<h1 class="page-title" th:text="${pageTitle}">Page</h1>
<th:block th:replace="${content}"></th:block>
</section>
</main>
</div>
</body>
</html>
@@ -21,7 +21,7 @@
<ul class="nav-list">
<li><a class="nav-link" th:href="@{/dashboard}" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/users}"><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="@{/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="@{/admin/calendar}"><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}"><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}"><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>
@@ -0,0 +1,66 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
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.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.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@WebMvcTest(AccountTemplateIntegrationTest.TemplateController.class)
@Import(AccountTemplateIntegrationTest.TemplateController.class)
class AccountTemplateIntegrationTest {
private final MockMvc mvc;
@Autowired
AccountTemplateIntegrationTest(MockMvc mvc) {
this.mvc = mvc;
}
@Test
void accountCreationUsesAuthenticatedShellAndRealAccountRoute() throws Exception {
mvc.perform(get("/template-contract/accounts/new")
.with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("action=\"/admin/accounts\"")))
.andExpect(content().string(containsString("href=\"/admin/accounts/new\"")))
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
}
@Test
void activationUsesPublicAuthShellAndLocalAssets() throws Exception {
mvc.perform(get("/template-contract/accounts/activate")
.with(user("pending@example.test")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("class=\"auth-shell\"")))
.andExpect(content().string(containsString("action=\"/activate\"")))
.andExpect(content().string(containsString("value=\"raw-token\"")))
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")))
.andExpect(content().string(containsString("href=\"/assets/app.css\"")));
}
@Controller
public static class TemplateController {
@GetMapping("/template-contract/accounts/new")
String accountCreation() {
return "accounts/new";
}
@GetMapping("/template-contract/accounts/activate")
String activation(Model model) {
model.addAttribute("token", "raw-token");
return "accounts/activate";
}
}
}