Add org-scoped co-author editing to the article editor#23128
Add org-scoped co-author editing to the article editor#23128benhalpern merged 10 commits intomainfrom
Conversation
Agent-Logs-Url: https://github.com/forem/forem/sessions/a27e5fbb-195f-4cd5-8195-97e7ae5e3127 Co-authored-by: benhalpern <3102842+benhalpern@users.noreply.github.com>
Agent-Logs-Url: https://github.com/forem/forem/sessions/a27e5fbb-195f-4cd5-8195-97e7ae5e3127 Co-authored-by: benhalpern <3102842+benhalpern@users.noreply.github.com>
Agent-Logs-Url: https://github.com/forem/forem/sessions/a27e5fbb-195f-4cd5-8195-97e7ae5e3127 Co-authored-by: benhalpern <3102842+benhalpern@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds organization-scoped co-author editing to the v2 article editor by extending the server-provided editor context, introducing a new co-author picker UI, and enforcing org membership for co-authors on org posts.
Changes:
- Extends editor JSON payload with article author/co-author data plus org-level permission + member lookup URL.
- Adds a new
CoAuthorSelectorcontrol in the editor options modal (with frontend tests) and clears selections when switching orgs. - Tightens backend handling: permits editor-driven
co_author_ids_listupdates only in allowed org/admin contexts and validates co-authors are active org members.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/requests/articles/articles_update_spec.rb | Adds request coverage for org-admin co-author updates and membership rejection. |
| spec/requests/articles/articles_create_spec.rb | Adds request coverage for create-time co-author assignment permissions (admin vs non-admin). |
| config/locales/en.yml | Adds i18n strings for the new co-author selector UI. |
| config/locales/fr.yml | Adds i18n strings for the new co-author selector UI. |
| config/locales/pt.yml | Adds i18n strings for the new co-author selector UI. |
| app/views/articles/_v2_form.html.erb | Serializes per-org can_add_co_authors and fetch_users_url into editor data attributes; includes author/co-author article fields. |
| app/models/article.rb | Enforces that co-authors on org posts must be active org members. |
| app/javascript/article-form/components/tests/CoAuthorSelector.test.jsx | Adds frontend tests for visibility, fetching, and editor-state syncing. |
| app/javascript/article-form/components/Options.jsx | Wires the new co-author selector into the editor options modal and updates prop types. |
| app/javascript/article-form/components/CoAuthorSelector.jsx | Implements the org-scoped co-author selector UI and member fetching. |
| app/javascript/article-form/articleForm.jsx | Adds co-author state to the editor and clears it when switching organizations. |
| app/controllers/articles_controller.rb | Updates strong-params logic to permit co_author_ids_list only when allowed, and refactors org-id handling for permission checks. |
| serialized_organizations = Array(organizations).map do |organization| | ||
| { | ||
| id: organization.id, | ||
| name: organization.name, | ||
| bg_color_hex: organization.bg_color_hex, | ||
| text_color_hex: organization.text_color_hex, | ||
| profile_image_90: organization.profile_image_90, | ||
| can_add_co_authors: current_user&.org_admin?(organization), | ||
| fetch_users_url: organization_members_path(slug: organization.slug, format: :json) | ||
| } |
There was a problem hiding this comment.
current_user&.org_admin?(organization) issues a DB query (organization_memberships.admin.exists?) per organization, which can create an N+1 on the editor page when a user belongs to many orgs. Consider precomputing the admin org IDs once (e.g., from current_user.organization_memberships.admin.pluck(:organization_id)) and using that set inside the serialization loop, or otherwise eager-loading/caching the admin membership check.
| def allowed_to_manage_org_co_authors? | ||
| organization_id = effective_organization_id | ||
|
|
||
| organization_id.present? && current_user.org_admin?(organization_id) | ||
| end | ||
|
|
||
| def effective_organization_id | ||
| return requested_organization_id if params["article"].key?("organization_id") && allowed_to_change_org_id? | ||
|
|
||
| @article&.organization_id | ||
| end |
There was a problem hiding this comment.
allowed_to_manage_org_co_authors? checks org-admin status against effective_organization_id, which switches to the requested organization_id whenever organization_id is present in params. This prevents clearing/updating co_author_ids_list during an org change (including switching to personal organization_id: ""), even though the editor clears co-authors on org switch; the result is that org changes can fail validation (co-authors no longer belong to the new org) with no way to clear them in the same request unless the user is admin of the target org. Consider authorizing co-author updates against the article’s current org when changing/removing org, or explicitly allowing co_author_ids_list to be cleared when organization_id is changing and the user is permitted to change org.
| def effective_organization_id | ||
| return requested_organization_id if params["article"].key?("organization_id") && allowed_to_change_org_id? | ||
|
|
||
| @article&.organization_id | ||
| end |
There was a problem hiding this comment.
There’s no request spec covering the org-switch scenario with existing co-authors (e.g., changing organization_id while co_author_ids_list is cleared), which is a key regression risk given the new effective_organization_id/permission logic and the new org-membership validation. Adding coverage for updating an org article with co-authors while switching to a different org (admin and non-admin target org cases) would help prevent breaking org changes in the editor.
| <select | ||
| aria-label="Select an organization" | ||
| name={name} | ||
| id={id} | ||
| className={className} | ||
| onBlur={onToggle} | ||
| onChange={onToggle} | ||
| > |
There was a problem hiding this comment.
The orgOptions logic uses strict equality (organizationId === organization.id) to set the selected <option>, but the onChange handler typically supplies e.target.value as a string while organization.id is a number. After a change, this can prevent the correct option from being marked selected on re-render. Consider coercing both sides to the same type (e.g., String(...)) or making the <select> controlled via a value={...} prop instead of setting selected on individual options.
| Organization.find_each do |organization| | ||
| membership = OrganizationMembership.find_or_initialize_by( | ||
| user_id: admin_user.id, | ||
| organization_id: organization.id | ||
| ) | ||
| membership.update!(type_of_user: "admin") | ||
|
|
||
| if organization.users.count < 3 | ||
| User.where.not(id: organization.users.pluck(:id)).limit(2).each do |other_user| | ||
| OrganizationMembership.find_or_create_by!( | ||
| user_id: other_user.id, | ||
| organization_id: organization.id | ||
| ) do |m| | ||
| m.type_of_user = "member" | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
This seeds loop performs multiple queries per organization (organization.users.count and organization.users.pluck(:id)), which can get slow with many orgs/users. Consider using already-loaded IDs (e.g., organization.user_ids) and avoiding repeated association queries inside the loop (or batching via preloading) to keep seeding fast.
| admin_org_id = user.organizations.first.id | ||
| article.update_columns(organization_id: admin_org_id) | ||
| co_author = create(:user) | ||
| create(:organization_membership, user: co_author, organization_id: admin_org_id) |
There was a problem hiding this comment.
In this spec setup, create(:organization_membership, user: co_author, organization_id: admin_org_id) will still build the factory's organization association and then overwrite organization_id, creating an extra unused Organization record. Prefer passing organization: Organization.find(admin_org_id) (or the already-created org object) to avoid unnecessary records and keep the setup accurate.
| create(:organization_membership, user: co_author, organization_id: admin_org_id) | |
| create(:organization_membership, user: co_author, organization: user.organizations.first) |
| @@ -0,0 +1,22 @@ | |||
| class DevToolsController < ApplicationController | |||
| before_action :ensure_development_environment | |||
| skip_before_action :verify_authenticity_token, only: [:sign_in_as] | |||
There was a problem hiding this comment.
sign_in_as disables CSRF verification (skip_before_action :verify_authenticity_token). Since the UI uses button_to, Rails will include an authenticity token automatically, so this skip is unnecessary and weakens protection (even in development, a cross-site POST could change the session in an unexpected way). Consider removing the CSRF skip (or at least restricting the action to same-origin/local requests).
| skip_before_action :verify_authenticity_token, only: [:sign_in_as] |
| <% @users.each do |user| %> | ||
| <tr> | ||
| <td><%= user.id %></td> | ||
| <td><strong><%= user.username %></strong></td> | ||
| <td><%= user.email %></td> | ||
| <td> | ||
| <span class="crayons-badge crayons-badge--brand mr-1"><%= user.roles.map(&:name).join(", ") %></span> | ||
| </td> |
There was a problem hiding this comment.
@users is loaded without preloading roles, but the view calls user.roles.map(...) for each row. This will issue an extra query per user (up to 100). Consider eager-loading roles in the controller (e.g., User.includes(:roles)), or otherwise avoiding per-row role lookups.
| def sign_in_as | ||
| user = User.find(params[:user_id]) | ||
| sign_in(user, bypass: true) | ||
| redirect_to "/", notice: "Successfully assumed identity of #{user.username} (Development Mode)" | ||
| end |
There was a problem hiding this comment.
sign_in_as allows any request in development to assume any user's identity (no authentication/authorization check beyond Rails.env.development?). If a dev server is ever reachable by others (shared LAN/tunnel), this becomes a serious escalation vector. Consider additionally restricting to request.local? and/or requiring an authenticated admin user before allowing impersonation.
| end | ||
|
|
||
| def co_author_ids_list=(list_of_co_author_ids) | ||
| self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip) |
There was a problem hiding this comment.
co_author_ids_list= does not handle blank/nil input. When the editor submits an empty string (common default/cleared state), split produces [""], causing co_author_ids to be non-blank and triggering invalid_coauthor validation errors. Treat blank input as an empty array and filter out blank IDs (and ideally cast to integers) before assigning to co_author_ids.
| self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip) | |
| self.co_author_ids = list_of_co_author_ids.to_s.split(",").filter_map do |co_author_id| | |
| cleaned_co_author_id = co_author_id.strip | |
| next if cleaned_co_author_id.blank? | |
| Integer(cleaned_co_author_id, exception: false) | |
| end |
| end | ||
|
|
||
| def co_authors_data | ||
| User.where(id: co_author_ids).select(:id, :name, :username).as_json |
There was a problem hiding this comment.
co_authors_data uses User.where(id: co_author_ids) without enforcing ordering, so the returned array can come back in an arbitrary order (which can cause UI flicker or mismatched ordering vs co_author_ids_list). Consider ordering with in_order_of(:id, co_author_ids) (or equivalent) so the serialized data matches the stored co-author order.
| User.where(id: co_author_ids).select(:id, :name, :username).as_json | |
| User.where(id: co_author_ids).in_order_of(:id, co_author_ids).select(:id, :name, :username).as_json |
| videoSourceUrl: this.article.video_source_url || null, | ||
| organizations, | ||
| organizationId: this.article.organization_id, | ||
| authorId: this.article.user_id, | ||
| coAuthorIdsList: this.article.co_author_ids_list || '', | ||
| coAuthorsData: this.article.co_authors_data || [], | ||
| errors: null, |
There was a problem hiding this comment.
ArticleForm stores organizations, authorId, and coAuthorsData in component state, and submitArticle currently sends almost the entire state back to the server (see processPayload in app/javascript/article-form/actions.js). With the new org fields (including fetch_users_url) and coAuthorsData objects, this can significantly bloat editor save requests and introduce unpermitted-param noise. Consider excluding UI-only fields (at least organizations, authorId, and coAuthorsData) from the submitted payload.
| get "/rails/mailers/*path", to: "rails/mailers#preview" | ||
|
|
There was a problem hiding this comment.
The new dev_tools endpoints are currently mounted for every non-production environment (unless Rails.env.production?). Even though the controller returns 403 outside development, having a privileged identity-assumption endpoint routable in staging/QA increases accidental exposure and attack surface. Consider scoping these routes to development only (e.g. if Rails.env.development?) or otherwise removing them from non-dev deployments entirely.
| get "/rails/mailers/*path", to: "rails/mailers#preview" | |
| get "/rails/mailers/*path", to: "rails/mailers#preview" | |
| end | |
| if Rails.env.development? |
When a post is being published under an organization, eligible org admins can now manage co-authors directly from the editor instead of switching to the dashboard. The UI stays fully hidden outside valid org/admin contexts so it does not appear where co-author assignment is not actionable.
Editor UX
Server-provided editor context
user_idco_author_ids_listcan_add_co_authorsPermissions / update handling
co_author_ids_listupdates from the editor only when the effective org context allows it.Data integrity
Coverage
{ "id": 42, "name": "Forem", "can_add_co_authors": true, "fetch_users_url": "/forem/members.json" }Also included in this PR: