diff --git a/app/controllers/boards/accesses_controller.rb b/app/controllers/boards/accesses_controller.rb new file mode 100644 index 000000000..fbb1fe97d --- /dev/null +++ b/app/controllers/boards/accesses_controller.rb @@ -0,0 +1,14 @@ +class Boards::AccessesController < ApplicationController + include BoardScoped + + def index + set_page_and_extract_portion_from @board.account.users.active.alphabetically.includes(:identity) + end + + private + def involvement_by_user + @involvement_by_user ||= @board.accesses.where(user_id: @page.records.map(&:id)).pluck(:user_id, :involvement).to_h + end + + helper_method :involvement_by_user +end diff --git a/app/views/boards/accesses/index.json.jbuilder b/app/views/boards/accesses/index.json.jbuilder new file mode 100644 index 000000000..37498fc1f --- /dev/null +++ b/app/views/boards/accesses/index.json.jbuilder @@ -0,0 +1,8 @@ +json.board_id @board.id +json.all_access @board.all_access? + +json.users @page.records do |user| + json.partial! "users/user", user: user + json.has_access involvement_by_user.key?(user.id) + json.involvement involvement_by_user[user.id] +end diff --git a/config/routes.rb b/config/routes.rb index 6c01c8d02..4d5acf988 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ Rails.application.routes.draw do resources :boards do scope module: :boards do + resources :accesses, only: :index resource :subscriptions resource :involvement resource :publication diff --git a/docs/api/sections/boards.md b/docs/api/sections/boards.md index ccaab44bd..fcce32762 100644 --- a/docs/api/sections/boards.md +++ b/docs/api/sections/boards.md @@ -136,6 +136,56 @@ __Response:__ Returns `204 No Content` on success. +## Board Accesses + +Board accesses let you see who has access to a board and their involvement level (watching or access only). Any board member can view this information. + +### `GET /:account_slug/boards/:board_id/accesses` + +Returns a paginated list of active account users with their access status for the specified board. + +__Response:__ + +```json +{ + "board_id": "03f5v9zkft4hj9qq0lsn9ohcm", + "all_access": false, + "users": [ + { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7", + "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar", + "has_access": true, + "involvement": "watching" + }, + { + "id": "03f5v9zppzlksuj4mxba2nbzn", + "name": "Kevin Clark", + "role": "admin", + "active": true, + "email_address": "kevin@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zppzlksuj4mxba2nbzn", + "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zppzlksuj4mxba2nbzn/avatar", + "has_access": false, + "involvement": null + } + ] +} +``` + +- `has_access` indicates whether the user has access to the board +- `involvement` is `"watching"`, `"access_only"`, or `null` (when the user does not have access) +- When `all_access` is `true`, all active account users have access to the board +- The `users` array contains the current page of results; if there are more users, follow the `Link` response header with `rel="next"` + +To change who has access, use `PUT /:account_slug/boards/:board_id` with the `user_ids` parameter. + ## Board Publications Publishing a board makes it publicly accessible via a shareable link, without requiring authentication. Only board administrators can publish or unpublish a board. diff --git a/test/controllers/boards/accesses_controller_test.rb b/test/controllers/boards/accesses_controller_test.rb new file mode 100644 index 000000000..6f883f19a --- /dev/null +++ b/test/controllers/boards/accesses_controller_test.rb @@ -0,0 +1,105 @@ +require "test_helper" + +class Boards::AccessesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "index returns active account users with access status" do + board = boards(:writebook) + + get board_accesses_path(board), as: :json + assert_response :success + + body = @response.parsed_body + assert_equal board.id, body["board_id"] + assert body["all_access"] + + user_ids = body["users"].map { |u| u["id"] } + accounts("37s").users.active.each do |user| + assert_includes user_ids, user.id + end + end + + test "index includes has_access and involvement for users with access" do + board = boards(:writebook) + board.access_for(users(:kevin)).update!(involvement: :watching) + + get board_accesses_path(board), as: :json + + kevin_entry = @response.parsed_body["users"].find { |u| u["id"] == users(:kevin).id } + assert kevin_entry["has_access"] + assert_equal "watching", kevin_entry["involvement"] + end + + test "index shows has_access false and nil involvement for users without access" do + board = boards(:private) + + get board_accesses_path(board), as: :json + assert_response :success + + david_entry = @response.parsed_body["users"].find { |u| u["id"] == users(:david).id } + assert_not david_entry["has_access"] + assert_nil david_entry["involvement"] + end + + test "index includes standard user fields" do + get board_accesses_path(boards(:writebook)), as: :json + + user_entry = @response.parsed_body["users"].first + assert user_entry.key?("id") + assert user_entry.key?("name") + assert user_entry.key?("role") + assert user_entry.key?("email_address") + assert user_entry.key?("avatar_url") + end + + test "index requires board access" do + logout_and_sign_in_as :david + board = boards(:private) + + get board_accesses_path(board), as: :json + assert_response :not_found + end + + test "index is accessible to non-admin board members" do + logout_and_sign_in_as :jz + board = boards(:writebook) + + get board_accesses_path(board), as: :json + assert_response :success + end + + test "index paginates account users" do + account = accounts("37s") + board = boards(:private) + + 200.times do |index| + identity = Identity.create!(email_address: "board-membership-#{index}@example.com") + account.users.create!(identity: identity, name: "Board Membership User #{index}", role: :member) + end + + expected_ids = account.users.active.alphabetically.pluck(:id) + actual_ids = [] + next_page = board_accesses_path(board, format: :json) + page_count = 0 + + while next_page + get next_page, as: :json + assert_response :success + + page_count += 1 + actual_ids.concat(@response.parsed_body["users"].map { |user| user["id"] }) + next_page = next_page_from_link_header(@response.headers["Link"]) + end + + assert_equal expected_ids, actual_ids + assert_operator page_count, :>, 1 + end + + private + def next_page_from_link_header(link_header) + url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first + URI.parse(url).request_uri if url + end +end