# Book Group Management Book group management lets a *book superuser* — a user who is allowed to manage a particular book — create groups and assign users to them, scoped to that one book. It gives book owners a self-service way to manage who can read their book without needing a global administrator for every change. The feature is available only to users who are allowed to manage the book. Ordinary readers never see the Groups tab or the endpoints described below. ## Overview A book superuser can: - Create groups that are scoped to a single book. - Search for users by name or email address. - Assign found users to one of the book's groups. - Remove members from a group. Groups created through this feature automatically grant read access to the book, so adding a user to a group is all that is needed to let them read it. ## How Groups Work A few rules govern how book groups are named and managed. They keep group ids stable (so permissions survive edits) while letting titles stay readable. - **You only provide a title.** When creating a group you enter a short title such as `Members`. The system generates the underlying group id for you. - **The title is prefixed with the book title.** A group you title `Members` on a book titled `My book` is stored as `My book - Members`. - **The id is generated and frozen.** The id is derived from the book title and your title (for example `my-book-members`). It is fixed at creation time and made unique — if that id already exists, a numeric suffix is appended (`my-book-members-2`, and so on). The id never changes afterwards. - **Renaming the book updates group titles automatically.** If the book is renamed, every group title is re-prefixed with the new book title. The group **id stays stable**, so existing permissions and member assignments are preserved. - **Scope is limited to the book.** A superuser can only manage groups that were created through this feature *on that book*. Groups belonging to other books, and groups created by any other means, are never listed or editable here. - **Groups cannot be deleted here.** Deleting a group is deliberately reserved for global administrators. There is no delete-group action or endpoint in this feature (see [No group deletion](#no-group-deletion)). ## Keycloak-managed sites On sites where groups are managed in Keycloak (Keycloak group sync is enabled), book groups are created **in Keycloak** rather than only locally: - Creating a group creates it in Keycloak using the book-prefixed title as the Keycloak group name (numbered — `My book - Members (2)` — if that name is already taken). The local Plone group is the standard Keycloak-synced shadow. - Assigning or removing a member propagates to the Keycloak group as well as the local group. - Renaming the book renames the Keycloak group so the titles keep following the book title. Everything else — the Groups tab, the endpoints below, and their responses — behaves the same. If Keycloak cannot be reached, group creation fails and nothing is created locally (the REST API responds with `503`); the superuser is asked to try again or contact an administrator. ## The Groups Tab For users who are allowed to manage the book, a **Groups** tab appears on the book at `//book-groups`. From this tab a superuser can: 1. Enter a title to create a new group for the book. 2. Search for users by name or email address. 3. Assign a found user to one of the book's groups. 4. Remove a member from a group. The tab is hidden from users who are not allowed to manage the book. ## REST API All endpoints operate on a book object and are available only to users who are allowed to manage that book. ### GET @book-groups Lists the groups created for this book, each with its members. ```javascript const response = await fetch('https://example.com/my-book/@book-groups', { headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ', }, }); ``` ```python import requests response = requests.get( 'https://example.com/my-book/@book-groups', headers={ 'Accept': 'application/json', 'Authorization': 'Bearer ', }, ) ``` **Response:** ```json { "@id": "https://example.com/my-book/@book-groups", "items": [ { "@id": "https://example.com/my-book/@book-groups/my-book-members", "id": "my-book-members", "title": "My book - Members", "user_title": "Members", "members": [ { "id": "jane.doe", "fullname": "Jane Doe", "email": "jane@example.com" } ] } ], "items_total": 1 } ``` The `title` is the full, book-prefixed group title; `user_title` is the plain title the superuser entered when creating the group. ### POST @book-groups Creates a new group for the book. You provide only the title; the id and the book-prefixed title are generated for you. The response is the created group object (same shape as an entry in the list above) with an empty `members` list. ```javascript const response = await fetch('https://example.com/my-book/@book-groups', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, body: JSON.stringify({ title: 'Members', }), }); ``` ```python import requests response = requests.post( 'https://example.com/my-book/@book-groups', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, json={ 'title': 'Members', }, ) ``` **Response (201):** ```json { "@id": "https://example.com/my-book/@book-groups/my-book-members", "id": "my-book-members", "title": "My book - Members", "user_title": "Members", "members": [] } ``` #### Request Body | Field | Required | Description | |---|---|---| | `title` | Yes | The plain group title (for example `Members`). It is prefixed with the book title in the stored group title. Must not be empty or whitespace-only. | #### Error Responses | Status | Error message | Cause | |---|---|---| | 400 | `Property 'title' is required` | The `title` field is missing or blank. | | 503 | `Could not create group in Keycloak` | On a Keycloak-managed site, the group could not be created in Keycloak (e.g. Keycloak unreachable). Nothing is created locally. | ### POST @book-groups/<group-id>/members Adds a member to one of the book's groups. Identify the user by their user id, or by their email address when the site manages users via an external identity provider. The response is the updated group object. ```javascript const response = await fetch( 'https://example.com/my-book/@book-groups/my-book-members/members', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, body: JSON.stringify({ user: 'jane.doe', }), }, ); ``` ```python import requests response = requests.post( 'https://example.com/my-book/@book-groups/my-book-members/members', headers={ 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': 'Bearer ', }, json={ 'user': 'jane.doe', }, ) ``` **Response:** ```json { "@id": "https://example.com/my-book/@book-groups/my-book-members", "id": "my-book-members", "title": "My book - Members", "user_title": "Members", "members": [ { "id": "jane.doe", "fullname": "Jane Doe", "email": "jane@example.com" } ] } ``` #### Request Body | Field | Required | Description | |---|---|---| | `user` | Yes | The user id of the member to add, or their email address when users are managed via an external identity provider. | #### Error Responses | Status | Error message | Cause | |---|---|---| | 404 | `Group not found` | `` is not a group managed for this book. | | 400 | *(message describing the lookup)* | No user could be found for the given id or email address. | ### DELETE @book-groups/<group-id>/members/<user-id> Removes a member from one of the book's groups. The response is the updated group object. ```javascript const response = await fetch( 'https://example.com/my-book/@book-groups/my-book-members/members/jane.doe', { method: 'DELETE', headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ', }, }, ); ``` ```python import requests response = requests.delete( 'https://example.com/my-book/@book-groups/my-book-members/members/jane.doe', headers={ 'Accept': 'application/json', 'Authorization': 'Bearer ', }, ) ``` **Response:** ```json { "@id": "https://example.com/my-book/@book-groups/my-book-members", "id": "my-book-members", "title": "My book - Members", "user_title": "Members", "members": [] } ``` #### Error Responses | Status | Error message | Cause | |---|---|---| | 404 | `Group not found` | `` is not a group managed for this book. | ### GET @book-group-user-search Searches for users to assign to a group, matching against name or email address. Use it to build a picker for the "add member" action. The `query` parameter is required; an empty query returns no items. ```javascript const response = await fetch( 'https://example.com/my-book/@book-group-user-search?query=jane', { headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ', }, }, ); ``` ```python import requests response = requests.get( 'https://example.com/my-book/@book-group-user-search', params={'query': 'jane'}, headers={ 'Accept': 'application/json', 'Authorization': 'Bearer ', }, ) ``` **Response:** ```json { "items": [ { "id": "jane.doe", "fullname": "Jane Doe", "email": "jane@example.com" } ], "items_total": 1 } ``` (no-group-deletion)= ## No group deletion There is deliberately no endpoint (and no UI action) for deleting a group through this feature. A book superuser can create groups and manage their members, but removing a group entirely is reserved for global administrators.