feat(ui): integrate account pages with shared shell
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+66
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user