diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 0110436..7bcc7af 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -48,6 +48,8 @@ mod common; pub mod issues; pub mod projects; +pub mod roles; pub mod time_entries; +pub mod users; pub use common::{identifier_to_u64, record_id_to_u64, wrap_in_doc, AppState, HandlerError}; diff --git a/crates/rm-handlers/src/roles.rs b/crates/rm-handlers/src/roles.rs new file mode 100644 index 0000000..bf33cd8 --- /dev/null +++ b/crates/rm-handlers/src/roles.rs @@ -0,0 +1,221 @@ +//! **W4b** — Role (`project_role` codebook id `0x0117`) list + +//! detail handlers. The RBAC role lookup; D3 (Plan §4 depth) wires +//! the per-permission gates over this same table. +//! +//! Routes (mounted by [`router`]): +//! +//! - `GET /roles` — list page (Name + Position) +//! - `GET /roles/:name` — detail page, looked up by role name slug + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::RoleRow; + +use crate::common::{identifier_to_u64, wrap_in_doc, AppState, HandlerError}; + +/// `GET /roles` — render the role list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let roles = state.store.list_roles().await?; + let cols = list_columns(); + let hrefs: Vec = roles.iter().map(|r| format!("/roles/{}", r.name)).collect(); + let positions: Vec = roles.iter().map(|r| r.position.to_string()).collect(); + let ids: Vec = roles.iter().map(|r| identifier_to_u64(&r.name)).collect(); + let rows: Vec> = roles + .iter() + .enumerate() + .map(|(idx, role)| RowSource { + record_id: ids[idx], + css_classes: "role", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &role.name, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { + value: &positions[idx], + }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list("Roles", 0x0117, "project_role", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Roles", &body))) +} + +/// `GET /roles/:name` — render a role's detail page. +pub async fn detail( + State(state): State, + Path(name): Path, +) -> Result, HandlerError> { + let role: RoleRow = state.store.find_role_by_name(&name).await?; + let cols = detail_columns(); + let href = format!("/roles/{}", role.name); + let position_str = role.position.to_string(); + let headline = format!( + "{}", + href, &role.name + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &role.name, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "num", + data: CellData::Plain { + value: &position_str, + }, + }, + ]; + let body = render_detail( + 0x0117, + "project_role", + identifier_to_u64(&role.name), + &headline, + &position_str, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc(&format!("Role: {}", &role.name), &body))) +} + +fn list_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("name", "Name", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("position", "Position", ColumnKind::Plain).sortable(), + ] +} + +fn detail_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("name", "Name", ColumnKind::PrimaryLink), + RenderColumn::new("position", "Position", ColumnKind::Plain), + ] +} + +/// Build the Role router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/roles", get(list)) + .route("/roles/:name", get(detail)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewRole, Store}; + use tower::ServiceExt; + + async fn app_with_roles(seed: &[(&str, i64)]) -> Router { + let store = Store::open().await.unwrap(); + for (name, position) in seed { + store + .create_role(NewRole { + name: name.to_string(), + position: *position, + }) + .await + .unwrap(); + } + router(AppState { store }) + } + + #[tokio::test] + async fn list_renders_empty_state() { + let app = app_with_roles(&[]).await; + let res = app + .oneshot( + Request::builder() + .uri("/roles") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0117\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn list_renders_seeded_roles() { + let app = app_with_roles(&[("Manager", 1), ("Developer", 2)]).await; + let res = app + .oneshot( + Request::builder() + .uri("/roles") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("Manager")); + assert!(s.contains("Developer")); + assert!(s.contains("href=\"/roles/Manager\"")); + assert!(s.contains("href=\"/roles/Developer\"")); + } + + #[tokio::test] + async fn detail_by_name_renders() { + let app = app_with_roles(&[("Manager", 1)]).await; + let res = app + .oneshot( + Request::builder() + .uri("/roles/Manager") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("Manager")); + assert!(s.contains("data-class-id=\"0x0117\"")); + } + + #[tokio::test] + async fn detail_404_for_unknown_role() { + let app = app_with_roles(&[]).await; + let res = app + .oneshot( + Request::builder() + .uri("/roles/Nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-handlers/src/users.rs b/crates/rm-handlers/src/users.rs new file mode 100644 index 0000000..43225aa --- /dev/null +++ b/crates/rm-handlers/src/users.rs @@ -0,0 +1,226 @@ +//! **W4a** — User (`project_actor` codebook id `0x0104`) list + +//! detail handlers. Top-level admin view of the actor identity. +//! +//! Routes (mounted by [`router`]): +//! +//! - `GET /users` — list page (Login + Display name) +//! - `GET /users/:login` — detail page, looked up by login slug +//! (Redmine's `/users/:login` convention) + +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use ogar_render_askama::{ + render_detail, render_list, CellData, CellSource, ColumnKind, RenderColumn, RowSource, +}; +use rm_store::UserRow; + +use crate::common::{identifier_to_u64, wrap_in_doc, AppState, HandlerError}; + +/// `GET /users` — render the user list. +pub async fn list(State(state): State) -> Result, HandlerError> { + let users = state.store.list_users().await?; + let cols = list_columns(); + let hrefs: Vec = users + .iter() + .map(|u| format!("/users/{}", u.login)) + .collect(); + let ids: Vec = users.iter().map(|u| identifier_to_u64(&u.login)).collect(); + let rows: Vec> = users + .iter() + .enumerate() + .map(|(idx, user)| RowSource { + record_id: ids[idx], + css_classes: "user", + group: None, + inline: vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &user.login, + href: &hrefs[idx], + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &user.display_name, + }, + }, + ], + block: Vec::new(), + }) + .collect(); + let body = render_list("Users", 0x0104, "project_actor", &cols, &[], &rows) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc("Users", &body))) +} + +/// `GET /users/:login` — render a user's detail page. +pub async fn detail( + State(state): State, + Path(login): Path, +) -> Result, HandlerError> { + let user: UserRow = state.store.find_user_by_login(&login).await?; + let cols = detail_columns(); + let href = format!("/users/{}", user.login); + let headline = format!( + "{}", + href, &user.display_name + ); + let cells = vec![ + CellSource { + column: &cols[0], + css_classes: "", + data: CellData::PrimaryLink { + label: &user.login, + href: &href, + }, + }, + CellSource { + column: &cols[1], + css_classes: "", + data: CellData::Plain { + value: &user.display_name, + }, + }, + ]; + let body = render_detail( + 0x0104, + "project_actor", + identifier_to_u64(&user.login), + &headline, + &user.login, + &cols, + &cells, + ) + .map_err(|e| HandlerError::Render(e.to_string()))?; + Ok(Html(wrap_in_doc( + &format!("{} ({})", &user.display_name, &user.login), + &body, + ))) +} + +fn list_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("login", "Login", ColumnKind::PrimaryLink) + .sortable() + .frozen(), + RenderColumn::new("display_name", "Name", ColumnKind::Plain).sortable(), + ] +} + +fn detail_columns() -> [RenderColumn; 2] { + [ + RenderColumn::new("login", "Login", ColumnKind::PrimaryLink), + RenderColumn::new("display_name", "Name", ColumnKind::Plain), + ] +} + +/// Build the User router. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/users", get(list)) + .route("/users/:login", get(detail)) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewUser, Store}; + use tower::ServiceExt; + + async fn app_with_users(seed: &[(&str, &str)]) -> Router { + let store = Store::open().await.unwrap(); + for (login, name) in seed { + store + .create_user(NewUser { + login: login.to_string(), + display_name: name.to_string(), + }) + .await + .unwrap(); + } + router(AppState { store }) + } + + #[tokio::test] + async fn list_renders_empty_state() { + let app = app_with_users(&[]).await; + let res = app + .oneshot( + Request::builder() + .uri("/users") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("data-class-id=\"0x0104\""), "{s}"); + assert!(s.contains("No data."), "{s}"); + } + + #[tokio::test] + async fn list_renders_seeded_users_with_detail_hrefs() { + let app = app_with_users(&[("admin", "Admin"), ("jsmith", "John Smith")]).await; + let res = app + .oneshot( + Request::builder() + .uri("/users") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("admin")); + assert!(s.contains("John Smith")); + assert!(s.contains("href=\"/users/admin\"")); + assert!(s.contains("href=\"/users/jsmith\"")); + } + + #[tokio::test] + async fn detail_by_login_renders() { + let app = app_with_users(&[("jsmith", "John Smith")]).await; + let res = app + .oneshot( + Request::builder() + .uri("/users/jsmith") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + let s = std::str::from_utf8(&body).unwrap(); + assert!(s.contains("John Smith"), "{s}"); + assert!(s.contains("data-class-id=\"0x0104\""), "{s}"); + } + + #[tokio::test] + async fn detail_404_for_unknown_login() { + let app = app_with_users(&[]).await; + let res = app + .oneshot( + Request::builder() + .uri("/users/nobody") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } +} diff --git a/crates/rm-server/src/router.rs b/crates/rm-server/src/router.rs index 79d7340..0ce1233 100644 --- a/crates/rm-server/src/router.rs +++ b/crates/rm-server/src/router.rs @@ -83,7 +83,9 @@ pub fn build_router_with(store: Store, auth_cfg: AuthConfig) -> Router { // path so parallel branches don't conflict on this file. ── .merge(rm_handlers::issues::router(state.clone())) // W1: /issues .merge(rm_handlers::projects::router(state.clone())) // W2: /projects + .merge(rm_handlers::roles::router(state.clone())) // W4b: /roles .merge(rm_handlers::time_entries::router(state.clone())) // W3: /time_entries + .merge(rm_handlers::users::router(state.clone())) // W4a: /users // ── Phase-0 auxiliary surfaces ── .merge(rm_auth::router(auth_cfg)) // /login, /logout, /me .layer(CookieManagerLayer::new()) diff --git a/crates/rm-store/src/lib.rs b/crates/rm-store/src/lib.rs index 733e8e4..88b8595 100644 --- a/crates/rm-store/src/lib.rs +++ b/crates/rm-store/src/lib.rs @@ -48,11 +48,15 @@ mod error; mod issue; mod project; +mod role; mod store; mod time_entry; +mod user; pub use error::StoreError; pub use issue::{IssueRow, NewIssue}; pub use project::{NewProject, ProjectRow}; +pub use role::{NewRole, RoleRow}; pub use store::Store; pub use time_entry::{NewTimeEntry, TimeEntryRow}; +pub use user::{NewUser, UserRow}; diff --git a/crates/rm-store/src/role.rs b/crates/rm-store/src/role.rs new file mode 100644 index 0000000..d392ec2 --- /dev/null +++ b/crates/rm-store/src/role.rs @@ -0,0 +1,112 @@ +//! Role (`project_role` codebook id `0x0117`) CRUD — the RBAC role +//! lookup. Both ports ship `Role` as the model name. +//! +//! W4 of the Redmine Integration Plan, second concept in the +//! actors-and-access track. Pair with [`crate::user`] for /users and +//! /roles top-level pages; nested `/projects/:id/members` (the +//! User × Project × Role join via `project_membership` + +//! `project_member_role`) is a W4 follow-up. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_role`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewRole { + /// Role name (`"Developer"`, `"Reporter"`, `"Manager"`, …). + pub name: String, + /// Sort position (1-based). Redmine + OP both surface this. + pub position: i64, +} + +/// Row returned by [`Store::find_role_by_name`] / [`Store::list_roles`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct RoleRow { + /// SurrealDB record id (`role:`). + pub id: Option, + /// Role name. + pub name: String, + /// Sort position. + pub position: i64, +} + +impl Store { + /// Insert a Role. + pub async fn create_role(&self, new: NewRole) -> Result { + let row: Option = self.db().create("role").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a Role by its name (used by the URL slug; Redmine's + /// `/roles/:id` uses numeric ids but our MVP keys on name). + pub async fn find_role_by_name(&self, name: &str) -> Result { + match self + .db() + .query("SELECT * FROM role WHERE name = $n LIMIT 1") + .bind(("n", name.to_string())) + .await + { + Ok(mut res) => match res.take::>(0) { + Ok(rows) => rows.into_iter().next().ok_or(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + }, + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every Role. + pub async fn list_roles(&self) -> Result, StoreError> { + match self.db().select::>("role").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_then_find_by_name() { + let store = Store::open().await.unwrap(); + store + .create_role(NewRole { + name: "Developer".to_string(), + position: 1, + }) + .await + .unwrap(); + let r = store.find_role_by_name("Developer").await.unwrap(); + assert_eq!(r.name, "Developer"); + assert_eq!(r.position, 1); + } + + #[tokio::test] + async fn find_by_name_returns_not_found_for_unknown() { + let store = Store::open().await.unwrap(); + let err = store.find_role_by_name("Nope").await.unwrap_err(); + assert!(matches!(err, StoreError::NotFound), "got {err:?}"); + } + + #[tokio::test] + async fn list_roles_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_roles().await.unwrap().is_empty()); + for (i, n) in ["Manager", "Developer", "Reporter"].iter().enumerate() { + store + .create_role(NewRole { + name: n.to_string(), + position: (i as i64) + 1, + }) + .await + .unwrap(); + } + let rows = store.list_roles().await.unwrap(); + assert_eq!(rows.len(), 3); + } +} diff --git a/crates/rm-store/src/user.rs b/crates/rm-store/src/user.rs new file mode 100644 index 0000000..92862df --- /dev/null +++ b/crates/rm-store/src/user.rs @@ -0,0 +1,133 @@ +//! User (`project_actor` codebook id `0x0104`) CRUD — the actor +//! identity, Redmine + OpenProject converge here via the STI fold +//! (`User` / `Principal` / `Group` all → `PROJECT_ACTOR`, per +//! `ogar_vocab::ports::*Port::class_id` → +//! `class_ids::PROJECT_ACTOR`). +//! +//! W4 of the Redmine Integration Plan, first concept in the +//! actors-and-access track. +//! +//! # Today's scope +//! +//! - In-store user shape carries `login` + `display_name` (no +//! password — `rm-auth`'s seed users still drive login; a sibling +//! `rm_credentials` table with argon2 hashes lands when the +//! seed-table-to-DB swap ships). +//! - Lookup by login slug (Redmine convention: `/users/:login`). +//! - Same schema-divergence caveat as W1/W2/W3: row goes in the +//! undeclared lowercase `user` table, not the SCHEMAFULL +//! PascalCase `ProjectActor`. + +use surrealdb_types::{RecordId, SurrealValue}; + +use crate::{Store, StoreError}; + +/// Input for [`Store::create_user`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct NewUser { + /// Lowercase login (URL slug). Redmine + OpenProject convention. + pub login: String, + /// Human-readable display name. + pub display_name: String, +} + +/// Row returned by [`Store::find_user_by_login`] / [`Store::list_users`]. +#[derive(Debug, Clone, SurrealValue)] +pub struct UserRow { + /// SurrealDB record id (`user:`). + pub id: Option, + /// Lowercase login. + pub login: String, + /// Display name. + pub display_name: String, +} + +impl Store { + /// Insert a User. + /// + /// # Errors + /// + /// - [`StoreError::Surreal`] / [`StoreError::NotFound`] same as + /// the other resources. + pub async fn create_user(&self, new: NewUser) -> Result { + let row: Option = self.db().create("user").content(new).await?; + row.ok_or(StoreError::NotFound) + } + + /// Find a User by login slug. + /// + /// # Errors + /// + /// - [`StoreError::NotFound`] when no row matches. + /// - [`StoreError::Surreal`] on driver failures. + pub async fn find_user_by_login(&self, login: &str) -> Result { + match self + .db() + .query("SELECT * FROM user WHERE login = $login LIMIT 1") + .bind(("login", login.to_string())) + .await + { + Ok(mut res) => match res.take::>(0) { + Ok(rows) => rows.into_iter().next().ok_or(StoreError::NotFound), + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + }, + Err(e) if e.is_not_found() => Err(StoreError::NotFound), + Err(e) => Err(StoreError::Surreal(e)), + } + } + + /// List every User, in insertion order. + pub async fn list_users(&self) -> Result, StoreError> { + match self.db().select::>("user").await { + Ok(rows) => Ok(rows), + Err(e) if e.is_not_found() => Ok(Vec::new()), + Err(e) => Err(StoreError::Surreal(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_then_find_by_login() { + let store = Store::open().await.unwrap(); + let inserted = store + .create_user(NewUser { + login: "jsmith".to_string(), + display_name: "John Smith".to_string(), + }) + .await + .unwrap(); + assert_eq!(inserted.login, "jsmith"); + let fetched = store.find_user_by_login("jsmith").await.unwrap(); + assert_eq!(fetched.login, "jsmith"); + assert_eq!(fetched.display_name, "John Smith"); + } + + #[tokio::test] + async fn find_by_login_returns_not_found_for_unknown() { + let store = Store::open().await.unwrap(); + let err = store.find_user_by_login("nope").await.unwrap_err(); + assert!(matches!(err, StoreError::NotFound), "got {err:?}"); + } + + #[tokio::test] + async fn list_users_empty_then_populated() { + let store = Store::open().await.unwrap(); + assert!(store.list_users().await.unwrap().is_empty()); + for (login, name) in [("admin", "Admin"), ("jsmith", "John Smith")] { + store + .create_user(NewUser { + login: login.to_string(), + display_name: name.to_string(), + }) + .await + .unwrap(); + } + let rows = store.list_users().await.unwrap(); + assert_eq!(rows.len(), 2); + } +}