Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/plans/2026-07-28-header-username.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Header Username Implementation Plan

**Goal:** Show the signed-in username in the site header (issue #126). Authenticated users currently see Dashboard, Courses, and Log out, but nothing tells them which account they are acting as; the username only appears in the dashboard greeting. This matters when switching between the student, instructor, and admin roles.

**Approach:** Reuse the existing display pattern from the dashboard greeting (`sec:authentication="name"`, Thymeleaf Spring Security extras already imported by the layout fragment). The username is user-typed data, so it is rendered escaped (inherent to the attribute processor) and truncated with CSS so it can never distort or spoof the header chrome. No ADR: this is a presentation change, no architectural decision involved.

**Out of scope:** a profile page or menu behind the username, and any change to what identifies a user (the `username` column stays the display name).

---

## Version Control (GitButler)

- Commit with `but commit feat/header-username -m "<msg>"` from the main repository.
- **NEVER push.** The user reviews in GitButler and pushes manually.

---

## Tasks

- [ ] `docs(plan): Add the header username plan` (this document)
- [ ] `feat(frontend): Show the signed-in username in the header` (closes #126)
- `fragments/layout.html`, authenticated `ul.nav__list`, before the Log out control:
`<li class="nav__user"><span class="visually-hidden">Signed in as </span><span class="nav__user-name" sec:authentication="name">username</span></li>`
(the `.visually-hidden` utility already exists in `base.css`; screen readers get the sentence, sighted users just see the name)
- `base.css`: `.nav__user` as non-interactive chrome (`color: var(--color-text-muted)`, `font-size: var(--font-size-sm)`); `.nav__user-name` with `max-width: 12rem`, `overflow: hidden`, `text-overflow: ellipsis`, `white-space: nowrap`, `display: inline-block` so a 50-character username (the database cap) truncates instead of wrapping the header
- MockMvc assertions: an authenticated page renders "Signed in as" plus the principal's username in the header; an anonymous page contains no `nav__user`
- [ ] `fix(frontend): Separate the account group from the nav and wrap on mobile`
(review feedback, issue #126 reopened: the username read as just another
nav item, and `.nav__list` never wrapped, so the 12rem username pushed
the Log out button off-screen on narrow viewports; the username and Log
out move out of the nav into `div.site-header__account` behind a border
divider, `.nav__list` gains `flex-wrap: wrap`, and the username width
caps at `40vw` under the 46rem breakpoint)

## Verification

- Full test suite and Checkstyle green.
- Browser pane, both themes: username visible next to Log out, muted; axe (WCAG 2.1 A/AA) reports no new violations; a 50-character username truncates with an ellipsis and causes no horizontal scroll.
- Demo data cleaned up afterwards; nothing pushed.
31 changes: 31 additions & 0 deletions src/main/resources/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ main:focus {
.nav__list {
list-style: none;
display: flex;
/* Without wrap, a crowded row pushes trailing items (the Log out
button, when it lived here) off-screen on narrow viewports. */
flex-wrap: wrap;
gap: var(--space-3);
margin: 0;
padding: 0;
Expand All @@ -211,6 +214,34 @@ main:focus {
box-shadow: inset 0 -2px 0 var(--color-primary);
}

/* The account group: status and exit, deliberately outside the nav. The
divider separates "where you can go" from "who you are". */
.site-header__account {
display: flex;
align-items: center;
gap: var(--space-3);
padding-left: var(--space-3);
border-left: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}

/* Usernames are user-typed (50 chars max in the schema): truncate rather
than let a long one wrap or stretch the header. */
.site-header__account-name {
display: inline-block;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

@media (max-width: 46rem) {
.site-header__account-name {
max-width: 40vw;
}
}

/* ---------- Layout ---------- */

.site-main {
Expand Down
18 changes: 13 additions & 5 deletions src/main/resources/templates/fragments/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,21 @@
<li sec:authorize="hasRole('ADMIN')">
<a class="nav__link" th:href="@{/admin/users}"
th:attr="aria-current=${current == 'admin'} ? 'page' : null">Admin</a></li>
<li>
<form th:action="@{/auth/logout}" method="post">
<button class="button button--ghost" type="submit">Log out</button>
</form>
</li>
</ul>
</nav>
<!-- Account status, not navigation: who am I acting as, and the
way out. Lives outside the nav so assistive tech browsing the
Main landmark only meets real links. The username is escaped
by the attribute processor and truncated by CSS: user-typed
data must not distort or spoof the header chrome. -->
<div class="site-header__account" sec:authorize="isAuthenticated()">
<span class="site-header__account-name"><span
class="visually-hidden">Signed in as </span><span
sec:authentication="name">username</span></span>
<form th:action="@{/auth/logout}" method="post">
<button class="button button--ghost" type="submit">Log out</button>
</form>
</div>
</div>
</header>
</th:block>
Expand Down
21 changes: 21 additions & 0 deletions src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import org.springframework.test.web.servlet.MockMvc;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
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.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
Expand Down Expand Up @@ -89,4 +91,23 @@ void register_form_renders_and_shows_field_errors() throws Exception {
.andExpect(content().string(containsString("aria-invalid=\"true\"")))
.andExpect(content().string(containsString("id=\"username-error\"")));
}

/**
* The header identifies the signed-in account on every page: sighted
* users see the username in the account group next to Log out, screen
* readers hear "Signed in as [name]" (issue #126). Anonymous visitors
* get no account group at all.
*/
@Test
void header_shows_the_signed_in_username() throws Exception {
mvc.perform(get("/").with(user("carol-header").roles("STUDENT")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("site-header__account")))
.andExpect(content().string(containsString("Signed in as")))
.andExpect(content().string(containsString("carol-header")));

mvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(content().string(not(containsString("site-header__account"))));
}
}