Skip to content

Add org-scoped co-author editing to the article editor#23128

Merged
benhalpern merged 10 commits intomainfrom
copilot/add-co-authors-to-article-editor
Apr 14, 2026
Merged

Add org-scoped co-author editing to the article editor#23128
benhalpern merged 10 commits intomainfrom
copilot/add-co-authors-to-article-editor

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 13, 2026

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

    • Adds a co-author picker to the v2 article editor.
    • Only renders when:
      • an organization is selected, and
      • the current user can manage co-authors for that org.
    • Clears editor co-author selections when switching to a different org to avoid cross-org leakage.
  • Server-provided editor context

    • Extends the editor payload with:
      • article user_id
      • article co_author_ids_list
      • per-organization can_add_co_authors
      • per-organization member lookup URL
    • Keeps the editor self-sufficient for org-aware rendering and suggestions.
  • Permissions / update handling

    • Permits co_author_ids_list updates from the editor only when the effective org context allows it.
    • Reuses the article’s current org unless a valid org change is being applied in the same request.
  • Data integrity

    • Validates that co-authors remain valid users.
    • Adds org membership enforcement so co-authors on org posts must be active members of the selected organization.
  • Coverage

    • Adds focused request coverage for editor-driven co-author create/update flows.
    • Adds frontend coverage for visibility and selection behavior of the new editor control.
data-organizations="<%= serialized_organizations.to_json %>"
{
  "id": 42,
  "name": "Forem",
  "can_add_co_authors": true,
  "fetch_users_url": "/forem/members.json"
}

Also included in this PR:

  • Useful seeds for testing this
  • Tools for changing users in development, again useful for testing this but generally useful going forward.

Copilot AI and others added 3 commits April 13, 2026 15:44
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>
@benhalpern benhalpern marked this pull request as ready for review April 13, 2026 17:46
@benhalpern benhalpern requested review from a team as code owners April 13, 2026 17:46
Copilot AI review requested due to automatic review settings April 13, 2026 17:46
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 CoAuthorSelector control in the editor options modal (with frontend tests) and clears selections when switching orgs.
  • Tightens backend handling: permits editor-driven co_author_ids_list updates 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.

Comment on lines +49 to +58
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)
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +385 to +395
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
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +395
def effective_organization_id
return requested_organization_id if params["article"].key?("organization_id") && allowed_to_change_org_id?

@article&.organization_id
end
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

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.

Copilot generated this review using guidance from repository custom instructions.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.

Comment on lines 40 to 46
<select
aria-label="Select an organization"
name={name}
id={id}
className={className}
onBlur={onToggle}
onChange={onToggle}
>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread db/seeds.rb
Comment on lines +213 to +229
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
create(:organization_membership, user: co_author, organization_id: admin_org_id)
create(:organization_membership, user: co_author, organization: user.organizations.first)

Copilot uses AI. Check for mistakes.
Comment thread app/controllers/dev_tools_controller.rb Outdated
@@ -0,0 +1,22 @@
class DevToolsController < ApplicationController
before_action :ensure_development_environment
skip_before_action :verify_authenticity_token, only: [:sign_in_as]
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
skip_before_action :verify_authenticity_token, only: [:sign_in_as]

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +28
<% @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>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

@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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.

Comment on lines +10 to +14
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread app/models/article.rb
end

def co_author_ids_list=(list_of_co_author_ids)
self.co_author_ids = list_of_co_author_ids.split(",").map(&:strip)
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread app/models/article.rb
end

def co_authors_data
User.where(id: co_author_ids).select(:id, :name, :username).as_json
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 179 to 185
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,
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread config/routes.rb
Comment on lines 436 to +437
get "/rails/mailers/*path", to: "rails/mailers#preview"

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
get "/rails/mailers/*path", to: "rails/mailers#preview"
get "/rails/mailers/*path", to: "rails/mailers#preview"
end
if Rails.env.development?

Copilot uses AI. Check for mistakes.
@benhalpern benhalpern merged commit 310851d into main Apr 14, 2026
19 of 20 checks passed
@benhalpern benhalpern deleted the copilot/add-co-authors-to-article-editor branch April 14, 2026 20:29
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.

3 participants