Skip to content

feat(rm-handlers): W4 — User + Role list + detail handlers#13

Merged
AdaWorldAPI merged 1 commit into
mainfrom
claude/w4-user-role-handlers
Jun 21, 2026
Merged

feat(rm-handlers): W4 — User + Role list + detail handlers#13
AdaWorldAPI merged 1 commit into
mainfrom
claude/w4-user-role-handlers

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

W4 from the Redmine Integration Plan

Actors-and-access track. Two top-level admin concepts in this PR.

Route Concept class_id
GET /users User list 0x0104 project_actor
GET /users/:login User detail (Redmine /users/:login convention)
GET /roles Role list 0x0117 project_role
GET /roles/:name Role detail by name slug

Scope 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

File Tests
rm-store/src/user.rs 3 — create+find, NotFound, list 0/2
rm-store/src/role.rs 3 — same shape
rm-handlers/src/users.rs 4 — empty-state 0x0104, list 2 w/ hrefs, detail by login, 404
rm-handlers/src/roles.rs 4 — empty-state 0x0117, list 2 w/ hrefs, detail by name, 404

All 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

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@AdaWorldAPI AdaWorldAPI merged commit d05674f into main Jun 21, 2026
1 check passed
AdaWorldAPI added a commit that referenced this pull request Jun 21, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant