diff --git a/docs/plans/2026-07-28-header-username.md b/docs/plans/2026-07-28-header-username.md new file mode 100644 index 0000000..357b55f --- /dev/null +++ b/docs/plans/2026-07-28-header-username.md @@ -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 ""` 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: + `` + (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. diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index f7986ce..38ad515 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -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; @@ -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 { diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index eaeecad..055e4f0 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -62,13 +62,21 @@
  • Admin
  • -
  • -
    - -
    -
  • + +
    + +
    + +
    +
    diff --git a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java index 8e98e0a..0a5da4b 100644 --- a/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java +++ b/src/test/java/com/ericbouchut/learndev/auth/AuthFlowTest.java @@ -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; @@ -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")))); + } }