feat(rm-handlers): W4 — User + Role list + detail handlers#13
Conversation
W4 of the Redmine Integration Plan, actors-and-access track. # Routes added - GET /users — User (project_actor 0x0104) list page - GET /users/:login — User detail by login slug - GET /roles — Role (project_role 0x0117) list page - GET /roles/:name — Role detail by name slug # Two concepts in one PR W4 in the plan groups User + Member + Role + MemberRole + Watcher. This PR ships the two top-level admin concepts (User + Role); the nested ones (Member under Project, MemberRole, Watcher under Issue) land in W4-followup PRs since they're join-shaped — better surfaced as embedded sub-sections on Project / Issue detail pages. # DoD pinned by 14 new tests Store (6): - user: create+find_by_login round-trip, NotFound, list 0/2 - role: create+find_by_name round-trip, NotFound, list 0/3 Handlers (8): - users: empty-state w/ class_id 0x0104, list 2 w/ login hrefs, detail by login slug, 404 unknown login - roles: empty-state w/ class_id 0x0117, list 2 w/ name hrefs, detail by name slug, 404 unknown role # Status: 4/8 width tracks shipped (User + Role count as one) W1 ✓ W2 ✓ W3 ✓ W4 (partial — admin pages) ✓. Remaining: W4 nested (Member, MemberRole, Watcher), W5 Taxonomy, W6 Comms, W7 SCM-light, W8 Queries/Relations. cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --workspace all green; 96 tests across 5 crates.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52c69683bd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| pub async fn list(State(state): State<AppState>) -> Result<Html<String>, HandlerError> { | ||
| let roles = state.store.list_roles().await?; | ||
| let cols = list_columns(); | ||
| let hrefs: Vec<String> = roles.iter().map(|r| format!("/roles/{}", r.name)).collect(); |
There was a problem hiding this comment.
Encode role names before putting them in paths
When a role name contains a reserved URL character such as #, ?, or / (role names are arbitrary admin labels, not URL-safe slugs), this emits /roles/<raw name> directly; the browser then treats #/? as a fragment/query or / as another path segment, so clicking the Roles list cannot reach GET /roles/:name for that row even though the store lookup would find it. Percent-encode the role name path segment here and in the detail self-link.
Useful? React with 👍 / 👎.
…ail-fast Closes the codex items left open across my Phase 0/1 PRs: # P1 — XSS via raw user content in handler-built HTML (#10, #12) `wrap_in_doc(title, body)` interpolated the raw `title` straight into the document's `<title>` element. With user-controlled inputs (issue subjects, time-entry spent_on, project names, user display names) reaching this fn, a payload like `</title><script>alert(1)</script>` is a stored XSS. The `render_detail(..., headline_html, ...)` parameter is contractually pre-rendered HTML (kit `|safe`s it through the template), so handlers that compose it from user data must escape themselves. W1 (Issue), W2 (Project), W3 (TimeEntry), W4a (User), W4b (Role) all did this with raw interpolation. Fix: - `common::html_escape` — minimal `& < > " '` escape, no extra dep. - `wrap_in_doc` escapes its `title` parameter. - Every handler that composes `headline_html` from user data now calls `html_escape` first. - New regression test `detail_escapes_xss_in_subject_for_title_and_headline` in `issues.rs` exercises the full XSS surface. # P2 — URL slug encoding (#13) `/roles/<name>`, `/projects/<identifier>`, `/users/<login>` paths interpolated the slug raw. A name with `#`/`?`/`/` would break the browser's URL parse (`#` = fragment, `/` = path segment). Fix: - `common::encode_path_segment` — percent-encodes everything outside RFC-3986 unreserved + path-safe chars. No new dep (rule is small). - Roles, projects, users all route hrefs through it. - New regression test `list_percent_encodes_role_names_with_reserved_chars` in `roles.rs` (Q/A → Q%2FA, R&D → R%26D). # P1 — RM_BIND fail-fast (#7) `std::env::var("RM_BIND").ok().and_then(|s| s.parse().ok())` collapsed unset and malformed into the same fallback. A typo silently started the server on the default. Now we distinguish: - Unset → default - Malformed → return `io::Error::other(...)` immediately # Skipped - #9 (auth routes not reachable) — already fixed by #10's `build_router_with` refactor that mounts `rm_auth::router(cfg)`. - #8 (lowercase vs PascalCase OGAR table names) — schema- divergence noted in W3's doc, lands in a dedicated PR; needs the per-port DDL emission story W4-followups will revisit. cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --workspace all green locally; 108 tests across 5 crates.
W4 from the Redmine Integration Plan
Actors-and-access track. Two top-level admin concepts in this PR.
GET /users0x0104 project_actorGET /users/:login/users/:loginconvention)GET /roles0x0117 project_roleGET /roles/:nameScope split
W4 in the plan groups User + Member + MemberRole + Watcher. This PR ships the two top-level admin concepts (User + Role); the nested ones (Member under Project, MemberRole, Watcher under Issue) land as W4-followup PRs since they're join-shaped — better surfaced as embedded sub-sections on Project / Issue detail pages than as standalone admin routes.
DoD pinned by 14 new tests
rm-store/src/user.rsrm-store/src/role.rsrm-handlers/src/users.rs0x0104, list 2 w/ hrefs, detail by login, 404rm-handlers/src/roles.rs0x0117, list 2 w/ hrefs, detail by name, 404All three CI gates green: 96 tests across 5 crates.
Status
4 of 8 width tracks shipped (W1 ✓ W2 ✓ W3 ✓ W4 partial ✓).
Per Plan §1.6 factoring: at this point the per-resource boilerplate is ~150 LOC each (handler + tests). A new track is a copy-paste-and-adapt of an existing module, not new design.
🤖 Generated with Claude Code