diff --git a/app/authorizers/book_authorizer.rb b/app/authorizers/book_authorizer.rb index 7393cdb6d..87aa61f48 100644 --- a/app/authorizers/book_authorizer.rb +++ b/app/authorizers/book_authorizer.rb @@ -19,7 +19,7 @@ def readable_by?(user) def updatable_by?(user) return true if user && resource.user_id == user.id - return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id) + return true if user && resource.universe.present? && resource.universe.contributors.where(role: Contributor::EDITING_ROLES).pluck(:user_id).include?(user.id) false end diff --git a/app/authorizers/content_authorizer.rb b/app/authorizers/content_authorizer.rb index e3a074bec..5e2ca1770 100644 --- a/app/authorizers/content_authorizer.rb +++ b/app/authorizers/content_authorizer.rb @@ -18,12 +18,12 @@ def readable_by? user def updatable_by? user return true if PermissionService.user_owns_any_containing_universe?(user: user, content: resource) - return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource) + return true if PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource) return true if [ PermissionService.content_has_no_containing_universe?(content: resource), PermissionService.user_owns_content?(user: user, content: resource) ].all? - + return false end @@ -34,7 +34,7 @@ def deletable_by? user PermissionService.user_owns_content?(user: user, content: resource) ].all? return true if [ - PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource), + PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource), PermissionService.user_owns_content?(user: user, content: resource) ].all? diff --git a/app/authorizers/content_page_authorizer.rb b/app/authorizers/content_page_authorizer.rb index 3bc57016c..d37eaef33 100644 --- a/app/authorizers/content_page_authorizer.rb +++ b/app/authorizers/content_page_authorizer.rb @@ -30,11 +30,11 @@ def readable_by?(user) def updatable_by?(user) return true if PermissionService.user_owns_content?(user: user, content: resource) - + if resource.page_type == 'Universe' - return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource) + return true if PermissionService.user_can_edit_universe_content?(user: user, universe: resource) else - return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource) + return true if PermissionService.user_can_edit_containing_universe_content?(user: user, content: resource) end return false diff --git a/app/authorizers/timeline_authorizer.rb b/app/authorizers/timeline_authorizer.rb index 837cc1bd5..8550cad39 100644 --- a/app/authorizers/timeline_authorizer.rb +++ b/app/authorizers/timeline_authorizer.rb @@ -19,8 +19,8 @@ def readable_by?(user) def updatable_by?(user) return true if user && resource.user_id == user.id - return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id) - + return true if user && resource.universe.present? && resource.universe.contributors.where(role: Contributor::EDITING_ROLES).pluck(:user_id).include?(user.id) + return false end diff --git a/app/authorizers/universe_core_content_authorizer.rb b/app/authorizers/universe_core_content_authorizer.rb index 3f0203e83..e51c0a246 100644 --- a/app/authorizers/universe_core_content_authorizer.rb +++ b/app/authorizers/universe_core_content_authorizer.rb @@ -19,7 +19,7 @@ def readable_by? user def updatable_by? user return true if PermissionService.user_owns_content?(user: user, content: resource) - return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource) + return true if PermissionService.user_can_edit_universe_content?(user: user, universe: resource) return false end diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb index cc4bca875..13bfdd2b3 100644 --- a/app/controllers/api/v1/api_controller.rb +++ b/app/controllers/api/v1/api_controller.rb @@ -93,7 +93,7 @@ def log_api_request page = content_type.find_by(id: params[:id].to_i) if page && page.readable_by?(@current_api_user || User.new) - render json: ApiContentSerializer.new(page, include_blank_fields: params.fetch(:include_blank_fields, false)).data + render json: ApiContentSerializer.new(page, include_blank_fields: params.fetch(:include_blank_fields, false), viewer: @current_api_user).data else render json: { error: "Page not found" } end diff --git a/app/controllers/api/v1/gallery_images_controller.rb b/app/controllers/api/v1/gallery_images_controller.rb index e94974a02..7fee118bc 100644 --- a/app/controllers/api/v1/gallery_images_controller.rb +++ b/app/controllers/api/v1/gallery_images_controller.rb @@ -17,12 +17,12 @@ def sort content_id = params[:content_id] content = content_type.constantize.find_by(id: content_id) - # Check permissions - must own or contribute to this content - unless content && - (content.user_id == current_user.id || - (content.respond_to?(:universe_id) && - content.universe_id.present? && - current_user.contributable_universe_ids.include?(content.universe_id))) + # Check permissions - must own or have edit access to this content + unless content && + (content.user_id == current_user.id || + (content.respond_to?(:universe_id) && + content.universe_id.present? && + current_user.editable_universe_ids.include?(content.universe_id))) return render json: { error: 'Unauthorized' }, status: :unauthorized end diff --git a/app/controllers/attribute_fields_controller.rb b/app/controllers/attribute_fields_controller.rb index 6bae07782..de079070f 100644 --- a/app/controllers/attribute_fields_controller.rb +++ b/app/controllers/attribute_fields_controller.rb @@ -285,7 +285,7 @@ def content_param_list :label, :description, :entity_type, :attribute_category_id, - :hidden, :position, + :hidden, :position, :privacy, field_options: [ :display_style, :input_size, diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index e6d88b1fa..8127a846d 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -18,6 +18,10 @@ class ContentController < ApplicationController before_action :set_navbar_actions, except: [:deleted, :api_sort] before_action :set_sidenav_expansion, except: [:api_sort] + before_action :verify_entity_edit_permission, only: [ + :link_field_update, :name_field_update, :text_field_update, :tags_field_update, :universe_field_update + ] + def index @content_type_class = content_type_from_controller(self.class) @content_type_name = @content_type_class.name @@ -250,9 +254,24 @@ def references document_ids = DocumentAnalysis.where(id: analysis_ids).pluck(:document_id) @documents = Document.where(id: document_ids) @references = @content.incoming_page_references.preload(:referencing_page) + + # Hide references coming from fields the viewer can't see on the referencing page + # (e.g. another page mentioning this one in a private or contributors-only field) + reference_fields = AttributeField.where(id: @references.map(&:attribute_field_id)).index_by(&:id) + @references = @references.select do |reference| + field = reference_fields[reference.attribute_field_id] + page = reference.referencing_page + + field.nil? || page.nil? || PermissionService.attribute_field_visible_to?( + field: field, + content: page, + viewer: current_user + ) + end + @mentioning_attributes = Attribute.where( - attribute_field_id: @references.pluck(:attribute_field_id), - entity_id: @references.pluck(:referencing_page_id) + attribute_field_id: @references.map(&:attribute_field_id), + entity_id: @references.map(&:referencing_page_id) ) end @@ -264,6 +283,14 @@ def new content.universe_id = @universe_scope.try(:id) if content.respond_to?(:universe_id) } + # Contributors without create permissions (Editors, Read-Only) can't add new pages to a universe + if @content.respond_to?(:universe_id) && !user_can_create_content_in_universe?(@content.universe_id) + return redirect_back( + fallback_location: root_path, + notice: "You don't have permission to create new pages in that universe." + ) + end + current_users_categories_and_fields = @content.class.attribute_categories(current_user) if current_users_categories_and_fields.empty? content_type_from_controller(self.class).create_default_attribute_categories(current_user) @@ -276,7 +303,7 @@ def new # For users who are creating premium content in a collaborated universe without premium of their own # we want to default that content into one of their collaborated unvierses. if !current_user.on_premium_plan? && Rails.application.config.content_types[:premium].map(&:name).include?(@content.class.name) - @content.universe_id = current_user.contributable_universes.first.try(:id) + @content.universe_id = current_user.creatable_universes.first.try(:id) end if params.key?(:document_entity) @@ -347,10 +374,18 @@ def create unless current_user.can_create?(content_type) \ || PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name) - + return redirect_back(fallback_location: root_path, notice: "Creating this type of page requires an active Premium subscription.") end + # Contributors without create permissions (Editors, Read-Only) can't add new pages to a universe + if @content.respond_to?(:universe_id) && !user_can_create_content_in_universe?(@content.universe_id) + return redirect_back( + fallback_location: root_path, + notice: "You don't have permission to create new pages in that universe." + ) + end + # Default names to untitled until one has been set unless [AttributeCategory, AttributeField, Attribute].map(&:name).include?(@content.class.name) @content.name ||= "Untitled #{content_type.name.downcase}" @@ -683,11 +718,11 @@ def toggle_image_pin return render json: { error: 'Content not found for this image' }, status: 422 end - # Need to check if user owns or contributes to the content directly + # Need to check if user owns or has edit access to the content directly unless content.user_id == current_user.id || (content.respond_to?(:universe_id) && content.universe_id.present? && - current_user.contributable_universe_ids.include?(content.universe_id)) + current_user.editable_universe_ids.include?(content.universe_id)) return render json: { error: 'Unauthorized' }, status: 403 end @@ -905,6 +940,41 @@ def universe_field_update private + # Whether the current user is allowed to create new pages inside the given universe. + # Content outside of any universe is only limited by the usual ownership/billing checks. + def user_can_create_content_in_universe?(universe_id) + return true if universe_id.blank? + + universe = Universe.find_by(id: universe_id) + return true if universe.nil? + return true if universe.user_id == current_user.id + + current_user.creatable_universe_ids.include?(universe.id) + end + + # Guards the per-field update endpoints so only users with edit access to the + # entity (owner, universe owner, or a contributor with an editing role) can write to it + def verify_entity_edit_permission + entity_type = entity_params.fetch(:entity_type, nil) + unless entity_type.present? && valid_content_types.include?(entity_type) + return render json: { error: 'Invalid entity type' }, status: 422 + end + + entity = entity_type.constantize.find_by(id: entity_params.fetch(:entity_id, nil).to_i) + return render json: { error: 'Not found' }, status: 404 if entity.nil? + + unless entity.updatable_by?(current_user) + return render json: { error: 'Unauthorized' }, status: 403 + end + + # Even with edit access to the page, a field that isn't visible to this user + # (private, or contributors-only when they aren't one) can't be written to + field = AttributeField.find_by(id: params[:field_id].to_i) + if field.present? && !PermissionService.attribute_field_visible_to?(field: field, content: entity, viewer: current_user) + render json: { error: 'Unauthorized' }, status: 403 + end + end + def group_events_by_date(events) # Group events by date for timeline display grouped = events.group_by { |event| event.created_at.to_date } diff --git a/app/controllers/contributors_controller.rb b/app/controllers/contributors_controller.rb index 4fb9c1974..beca0992e 100644 --- a/app/controllers/contributors_controller.rb +++ b/app/controllers/contributors_controller.rb @@ -11,21 +11,56 @@ def create end email = params[:contributor][:email]&.downcase - + role = params[:contributor][:role] + role = 'full' unless Contributor::ROLES.key?(role) + # Check if this email is already a contributor if universe.contributors.exists?(email: email) redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'This user is already a contributor.' return end - + # Use the ContributorService to handle the invitation - ContributorService.invite_contributor_to_universe(universe: universe, email: email) - + ContributorService.invite_contributor_to_universe(universe: universe, email: email, role: role) + redirect_to edit_universe_path(universe, anchor: 'contributors'), notice: 'Contributor invitation sent!' rescue StandardError => e redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Failed to add contributor. Please try again.' end - + + def update + contributor = Contributor.find(params[:id]) + universe = contributor.universe + + # Only the universe owner can change contributor roles + unless universe.user_id == current_user.id + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Only the universe owner can change contributor roles.' + return + end + + role = params.dig(:contributor, :role) + unless Contributor::ROLES.key?(role) + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'That is not a valid contributor role.' + return + end + + if contributor.update(role: role) + # Let the contributor know their access level changed + contributor.user.notifications.create( + message_html: "
Your role in the #{universe.name} universe has been changed to #{contributor.role_label}.
", + icon: Universe.icon, + icon_color: Universe.color, + happened_at: DateTime.current, + passthrough_link: Rails.application.routes.url_helpers.universe_path(universe), + reference_code: 'contributor-role-changed' + ) if contributor.user.present? + + redirect_to edit_universe_path(universe, anchor: 'contributors'), notice: "#{contributor.user&.display_name || contributor.email} is now a #{contributor.role_label}." + else + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Failed to update contributor role. Please try again.' + end + end + def destroy contributor = Contributor.find(params[:id]) relevant_universe = Universe.find(contributor.universe_id) diff --git a/app/models/page_data/attribute_field.rb b/app/models/page_data/attribute_field.rb index 4724503ae..0dc56e7ea 100644 --- a/app/models/page_data/attribute_field.rb +++ b/app/models/page_data/attribute_field.rb @@ -26,6 +26,18 @@ class AttributeField < ApplicationRecord UNDELETEABLE_FIELD_TYPES = %w(name universe tags) SETTABLE_FIELD_TYPES = %w(text_area page_link) + # Who can see this field (and its values) on pages that use it: + # - public: anyone who can view the page + # - contributors: the page owner, the universe owner, and contributors to the containing universe + # - private: only the page owner + VISIBILITIES = { + 'public' => 'Everyone who can view the page', + 'contributors' => 'Universe contributors only', + 'private' => 'Only me' + }.freeze + + validates :privacy, inclusion: { in: VISIBILITIES.keys } + # todo replace old_column_source etc #json :acceptable_page_link_classes @@ -68,7 +80,23 @@ def humanize end def private? - privacy != 'public' + effective_privacy != 'public' + end + + # The privacy level actually in effect for this field. Legacy Private Notes + # fields (old_column_source == 'private_notes') have always been hidden from + # other viewers, so they can be opened up to contributors but never made fully + # public -- for everything else the privacy column is authoritative. + def effective_privacy + return privacy if VISIBILITIES.key?(privacy) && privacy != 'public' + return 'private' if old_column_source == 'private_notes' + + 'public' + end + + # Legacy Private Notes fields can't be made fully public (see effective_privacy) + def can_be_public? + old_column_source != 'private_notes' end def system? diff --git a/app/models/serializers/api_content_serializer.rb b/app/models/serializers/api_content_serializer.rb index 266fed070..53d2fd08f 100644 --- a/app/models/serializers/api_content_serializer.rb +++ b/app/models/serializers/api_content_serializer.rb @@ -15,7 +15,7 @@ class ApiContentSerializer attr_accessor :data - def initialize(content, include_blank_fields: false) + def initialize(content, include_blank_fields: false, viewer: nil) self.categories = content.class.attribute_categories(content.user).where(hidden: [false, nil]).eager_load(attribute_fields: :attribute_values) self.fields = AttributeField.where(attribute_category_id: self.categories.map(&:id), hidden: [false, nil]) self.attribute_values = Attribute.where(attribute_field_id: self.fields.map(&:id), entity_type: content.page_type, entity_id: content.id).order('created_at desc') @@ -54,9 +54,9 @@ def initialize(content, include_blank_fields: false) id: category.id, label: category.label, icon: category.icon, - fields: category.attribute_fields.order(:position).reject { |field| - # Filter out private fields from API responses - field.old_column_source == 'private_notes' + fields: category.attribute_fields.order(:position).select { |field| + # Filter out fields the API viewer doesn't have visibility into + PermissionService.attribute_field_visible_to?(field: field, content: content, viewer: viewer) }.map { |field| { id: field.id, diff --git a/app/models/serializers/content_serializer.rb b/app/models/serializers/content_serializer.rb index 1093a99c2..ff5fc4c55 100644 --- a/app/models/serializers/content_serializer.rb +++ b/app/models/serializers/content_serializer.rb @@ -80,13 +80,13 @@ def initialize(content, viewing_user: nil) icon: category.icon, hidden: !!category.hidden, fields: self.fields.select { |field| field.attribute_category_id == category.id }.map { |field| - # Check if this is a private field (e.g., private_notes) - is_private_field = field.old_column_source == 'private_notes' - # Only the content owner can see private fields - viewer_is_owner = self.viewing_user.present? && content.user_id == self.viewing_user.id - - # Skip private fields entirely if viewer is not the owner - next nil if is_private_field && !viewer_is_owner + # Skip fields the viewer doesn't have visibility into + # (public / contributors / private -- see AttributeField::VISIBILITIES) + next nil unless PermissionService.attribute_field_visible_to?( + field: field, + content: content, + viewer: self.viewing_user + ) { internal_id: field.id, @@ -99,7 +99,8 @@ def initialize(content, viewing_user: nil) options: field.field_options, migrated_link: field.migrated_from_legacy, old_column_source: field.old_column_source, - private: is_private_field + privacy: field.effective_privacy, + private: field.effective_privacy == 'private' } }.compact.sort do |a, b| if a[:position] && b[:position] diff --git a/app/models/users/contributor.rb b/app/models/users/contributor.rb index c06c05fb1..04c2e3d5f 100644 --- a/app/models/users/contributor.rb +++ b/app/models/users/contributor.rb @@ -1,4 +1,42 @@ class Contributor < ApplicationRecord belongs_to :universe belongs_to :user, optional: true + + # Roles, from most to least permissive: + # - full: can view, edit, and create content in the universe + # - editor: can view and edit existing content, but not create new content + # - read_only: can view all content in the universe, but not edit anything + ROLES = { + 'full' => 'Full Contributor', + 'editor' => 'Editor', + 'read_only' => 'Read-Only' + }.freeze + + # Roles that grant write access to existing content + EDITING_ROLES = %w(full editor).freeze + + # Roles that grant the ability to create new content in the universe + CREATING_ROLES = %w(full).freeze + + validates :role, inclusion: { in: ROLES.keys } + + def role_label + ROLES.fetch(role, ROLES.fetch('full')) + end + + def can_edit_content? + EDITING_ROLES.include?(role) + end + + def can_create_content? + CREATING_ROLES.include?(role) + end + + def role_description + case role + when 'full' then 'Can view, edit, and create content in this universe' + when 'editor' then 'Can view and edit existing content, but not create new pages' + when 'read_only' then 'Can view all content in this universe, but not edit anything' + end + end end diff --git a/app/models/users/user.rb b/app/models/users/user.rb index 642efc56a..5682ba4dd 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -131,6 +131,7 @@ def contributable_universes @cached_user_contributable_universes ||= Universe.where(id: contributable_universe_ids) end + # Universes this user has any level of contributor access to (read access at minimum) def contributable_universe_ids # TODO: email confirmation needs to happen for data safety / privacy (only verified emails) @contributable_universe_ids ||= Contributor.where('email = ? OR user_id = ?', self.email, self.id).pluck(:universe_id) @@ -139,6 +140,20 @@ def contributable_universe_ids @contributable_universe_ids.uniq end + # Universes this user can edit existing content in (full contributors and editors) + def editable_universe_ids + @editable_universe_ids ||= contributor_universe_ids_with_roles(Contributor::EDITING_ROLES) + end + + # Universes this user can create new content in (full contributors only) + def creatable_universe_ids + @creatable_universe_ids ||= contributor_universe_ids_with_roles(Contributor::CREATING_ROLES) + end + + def creatable_universes + @cached_user_creatable_universes ||= Universe.where(id: creatable_universe_ids) + end + # TODO: rename this to #{content_type}_shared_with_me Rails.application.config.content_types[:all_non_universe].each do |content_type| pluralized_content_type = content_type.name.downcase.pluralize @@ -415,6 +430,17 @@ def favorite_page_type_icon private + # Universe IDs where this user is a contributor with one of the given roles, + # plus universes this user owns that have contributors (owners retain full access) + def contributor_universe_ids_with_roles(roles) + ids = Contributor.where('email = ? OR user_id = ?', self.email, self.id) + .where(role: roles) + .pluck(:universe_id) + ids += Contributor.where(universe_id: my_universe_ids).pluck(:universe_id) + + ids.uniq + end + # Attributes that are non-public, and should be blacklisted from any public # export (ex. in the JSON api, or SEO meta info about the user) def blacklisted_attributes diff --git a/app/services/contributor_service.rb b/app/services/contributor_service.rb index d33f91204..7c25407aa 100644 --- a/app/services/contributor_service.rb +++ b/app/services/contributor_service.rb @@ -1,5 +1,5 @@ class ContributorService < Service - def self.invite_contributor_to_universe(universe:, email:) + def self.invite_contributor_to_universe(universe:, email:, role: 'full') # First, look up whether a user already exists for this invite related_user = User.find_by(email: email.downcase) @@ -7,7 +7,8 @@ def self.invite_contributor_to_universe(universe:, email:) Contributor.create( universe: universe, email: email.downcase, - user: related_user + user: related_user, + role: role ) # If the user doesn't already have a Notebook.ai account, send them an invite diff --git a/app/services/permission_service.rb b/app/services/permission_service.rb index c7820b85f..8d2654074 100644 --- a/app/services/permission_service.rb +++ b/app/services/permission_service.rb @@ -12,10 +12,21 @@ def self.user_owns_any_containing_universe?(user:, content:) content.respond_to?(:universe) && content.universe.present? && user_owns_content?(user: user, content: content.universe) end + # Read-level access: the user is a contributor (of any role) to this universe def self.user_can_contribute_to_universe?(user:, universe:) user.present? && user.contributable_universes.pluck(:id).include?(universe.id) end + # Edit-level access: the user is a Full Contributor or Editor in this universe + def self.user_can_edit_universe_content?(user:, universe:) + user.present? && user.editable_universe_ids.include?(universe.id) + end + + # Create-level access: the user is a Full Contributor in this universe + def self.user_can_create_universe_content?(user:, universe:) + user.present? && user.creatable_universe_ids.include?(universe.id) + end + def self.content_is_public?(content:) content.respond_to?(:privacy) && content.privacy == 'public' end @@ -24,10 +35,11 @@ def self.content_is_in_a_public_universe?(content:) content.respond_to?(:universe) && content.universe.present? && self.content_is_public?(content: content.universe) end + # Read-level access to the universe containing this content (contributor of any role) def self.user_can_contribute_to_containing_universe?(user:, content:) # Early return if no user is provided return false if user.nil? - + # Special case for attribute-related content return true if [AttributeCategory, AttributeField, Attribute].include?(content.class) #todo audit this @@ -41,10 +53,51 @@ def self.user_can_contribute_to_containing_universe?(user:, content:) return false end + # Edit-level access to the universe containing this content (Full Contributor or Editor) + def self.user_can_edit_containing_universe_content?(user:, content:) + return false if user.nil? + + # Special case for attribute-related content, mirroring user_can_contribute_to_containing_universe? + return true if [AttributeCategory, AttributeField, Attribute].include?(content.class) #todo audit this + + return false if content.universe_id.nil? + + return true if user.respond_to?(:editable_universe_ids) && user.editable_universe_ids.include?(content.universe_id) + return true if user.respond_to?(:universes) && user.universes.pluck(:id).include?(content.universe_id) + + return false + end + def self.content_has_no_containing_universe?(content:) content.universe.nil? end + # Whether the given attribute field (and its value) on the given content page is + # visible to the given viewer. This is the single source of truth for field-level + # privacy; see AttributeField::VISIBILITIES for the levels. + def self.attribute_field_visible_to?(field:, content:, viewer:) + case field.effective_privacy + when 'private' + # Only the page owner can see private fields + user_owns_content?(user: viewer, content: content) + when 'contributors' + return false if viewer.nil? + return true if viewer.try(:site_administrator?) + return true if user_owns_content?(user: viewer, content: content) + return true if user_owns_any_containing_universe?(user: viewer, content: content) + + if content.is_a?(Universe) + user_can_contribute_to_universe?(user: viewer, universe: content) + else + content.respond_to?(:universe_id) && + content.universe_id.present? && + user_can_contribute_to_containing_universe?(user: viewer, content: content) + end + else # 'public' + true + end + end + def self.user_is_on_premium_plan?(user:) user.on_premium_plan? end @@ -61,7 +114,9 @@ def self.billing_plan_allows_extended_content?(user:) end def self.user_can_collaborate_in_universe_that_allows_extended_content?(user:) - user.contributable_universes.any? do |universe| + # Only universes the user can create new content in count here, since this + # permission gates creating extended content types + user.creatable_universes.any? do |universe| universe.user.on_premium_plan? # billing_plan_allows_extended_content?(user: universe.user) || user_has_active_promotion_for_this_content_type(user: universe.user, content_type: Universe) end @@ -73,7 +128,7 @@ def self.billing_plan_allows_collective_content?(user:) end def self.user_can_collaborate_in_universe_that_allows_collective_content?(user:) - user.contributable_universes.any? do |universe| + user.creatable_universes.any? do |universe| universe.user.on_premium_plan? # billing_plan_allows_collective_content?(user: universe.user) || user_has_active_promotion_for_this_content_type(user: universe.user, content_type: Universe) end diff --git a/app/views/content/attributes/tailwind/_field_config.html.erb b/app/views/content/attributes/tailwind/_field_config.html.erb index 350055ca8..a142dd647 100644 --- a/app/views/content/attributes/tailwind/_field_config.html.erb +++ b/app/views/content/attributes/tailwind/_field_config.html.erb @@ -390,15 +390,30 @@ Note: All <%= content_type.downcase %> pages are private by default, but these settings affect any page that is public. -
- - id="field_private" - class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2" - style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;"> - -
-

Private fields (and their answers) are only visible to you, even when your pages are shared publicly.

+ <%= form_for(field, method: :put, html: { class: 'space-y-3', 'data-type': 'json' }, remote: true) do |f| %> +
+ <% { + 'public' => ['Public', 'Visible to everyone who can view the page'], + 'contributors' => ['Contributors only', 'Visible to you, the universe owner, and universe contributors — hidden from the public'], + 'private' => ['Only me', 'Visible only to you, even on public pages and shared universes'] + }.each do |privacy_value, (privacy_label, privacy_help)| %> + <% next if privacy_value == 'public' && !field.can_be_public? %> +
+ <%= f.radio_button :privacy, privacy_value, + checked: field.effective_privacy == privacy_value, + id: "field_privacy_#{field.id}_#{privacy_value}", + class: "mt-0.5 h-4 w-4 border-gray-300 focus:ring-2 focus:ring-offset-2", + style: "color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> + +
+ <% end %> +
+ <%= f.submit 'Update visibility', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> + <% end %> +

This setting applies to this field on all of your <%= content_type.downcase %> pages, since fields are part of your <%= content_type.downcase %> template.

diff --git a/app/views/content/changelog/_date_changes.html.erb b/app/views/content/changelog/_date_changes.html.erb index d38a2dced..0a9ae0582 100644 --- a/app/views/content/changelog/_date_changes.html.erb +++ b/app/views/content/changelog/_date_changes.html.erb @@ -52,15 +52,12 @@ old_value = change.first.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.first.to_s new_value = change.second.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.second.to_s - # Privacy check - visible_change = true - if related_field.label.start_with?('Private') - visible_change = user_signed_in? && ( - (content.raw_model.is_a?(Universe) && content.user == current_user) || - (content.respond_to?(:universe) && content.universe && content.universe.user == current_user) || - (content.respond_to?(:universe) && content.universe.nil? && content.user == current_user) - ) - end + # Privacy check: hide values of fields the viewer doesn't have visibility into + visible_change = PermissionService.attribute_field_visible_to?( + field: related_field, + content: content.raw_model, + viewer: user_signed_in? ? current_user : nil + ) unless visible_change old_value = ContentChangeEvent::PRIVATE_PLACEHOLDER diff --git a/app/views/content/display/_contributors_user_list.html.erb b/app/views/content/display/_contributors_user_list.html.erb index 51aaeaf65..a7d61e02c 100644 --- a/app/views/content/display/_contributors_user_list.html.erb +++ b/app/views/content/display/_contributors_user_list.html.erb @@ -20,6 +20,9 @@

<%= contributor.user ? link_to(contributor.user.name, contributor.user, class: "hover:underline") : "#{contributor.email} (invited)" %> + + <%= contributor.role_label %> +

Invited <%= time_ago_in_words contributor.created_at %> ago diff --git a/app/views/content/form/_contributors.html.erb b/app/views/content/form/_contributors.html.erb index c6170a938..1e0682d79 100644 --- a/app/views/content/form/_contributors.html.erb +++ b/app/views/content/form/_contributors.html.erb @@ -8,17 +8,17 @@ Universes can have an unlimited number of collaborators.

- When a user is added as a collaborator to a universe, they are allowed to: + What a collaborator can do depends on their role:

- They are NOT allowed to: + Regardless of role, they are NOT allowed to:

- - <%= button_to remove_contributor_path(contributor.id), + +
+ <%= form_with url: update_contributor_role_path(contributor.id), method: :patch, local: true, html: { class: "inline-block" } do %> + + <% end %> + + <%= button_to remove_contributor_path(contributor.id), method: :delete, form: { class: "inline-block", @@ -68,8 +81,9 @@ }, class: "text-red-500 hover:bg-red-50 dark:hover:bg-red-900 p-2 rounded transition-colors", data: { turbo: false } do %> - remove_circle - <% end %> + remove_circle + <% end %> +
<% end %> @@ -93,15 +107,23 @@
info
-

About Contributors

+

About Contributor Roles

+