diff --git a/app/controllers/system/admin/marketplace_access_blocks_controller.rb b/app/controllers/system/admin/marketplace_access_blocks_controller.rb new file mode 100644 index 00000000000..3bb19d952a6 --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_blocks_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller + def create + block = Course::Assessment::Marketplace::AccessBlock.new( + user_id: params[:user_id], creator: current_user + ) + if block.save + render json: { id: block.id, userId: block.user_id }, status: :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def destroy + block = Course::Assessment::Marketplace::AccessBlock.find(params[:id]) + if block.destroy + head :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end +end diff --git a/app/controllers/system/admin/marketplace_access_controller.rb b/app/controllers/system/admin/marketplace_access_controller.rb new file mode 100644 index 00000000000..50f21b0da89 --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessController < System::Admin::Controller + def index + query = Course::Assessment::Marketplace::AccessListQuery.new + @rows = query.rows + @summary = query.summary + end +end diff --git a/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb new file mode 100644 index 00000000000..80d4b8ff007 --- /dev/null +++ b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller + # `preview` is a collection action with no id, so CanCan's default loader would try + # `find(params[:id])`. It builds its own unsaved rule; System::Admin::Controller's + # `authorize_admin` already gates the whole controller. + load_and_authorize_resource :allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule', + parent: false, except: [:preview] + + def index + # "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule` + # and show only the scoped rules in the table. + @everyone_rule = @allowlist_rules.rule_type_everyone.first + @allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance) + end + + def create + if @allowlist_rule.save + # `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors + # System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`). + render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def preview + rule = Course::Assessment::Marketplace::AllowlistRule.new(allowlist_rule_params) + unless rule.valid? + render json: { errors: rule.errors.full_messages.to_sentence }, status: :bad_request + return + end + + query = Course::Assessment::Marketplace::RulePreviewQuery.new(rule) + @rows = query.rows + @summary = query.summary + end + + def destroy + if @allowlist_rule.destroy + head :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + private + + def allowlist_rule_params + params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain, :email) + end +end diff --git a/app/helpers/system/admin/marketplace_access_helper.rb b/app/helpers/system/admin/marketplace_access_helper.rb new file mode 100644 index 00000000000..364d0cf864c --- /dev/null +++ b/app/helpers/system/admin/marketplace_access_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module System::Admin::MarketplaceAccessHelper + # The value half of a rule's label ("Email domain · "), for the audit list's reason column. + # @param [Course::Assessment::Marketplace::AllowlistRule] rule + # @return [String, nil] + def marketplace_rule_label_value(rule) + case rule.rule_type + when 'user' then rule.user&.name + when 'instance' then rule.instance&.name + when 'email_domain' then rule.email_domain + end + end +end diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index e7a7972085c..45dc1e7922b 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -4,12 +4,46 @@ module Course::AssessmentMarketplaceAbilityComponent def define_permissions allow_admins_publish_to_marketplace if user&.administrator? - allow_managers_access_marketplace if course_user&.manager_or_owner? + # System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not + # emit a `cannot` for them or it would revoke that. For everyone else, access is per-person. + if course && !user&.administrator? + if can_access_marketplace? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` + # regardless of the allow-list. This component runs after that one in the `define_permissions` + # super chain, so a `cannot` here takes precedence. This line is load-bearing. + cannot :access_marketplace, Course, id: course.id + end + end super end private + # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns + # >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list + # may browse, whatever their role in the course they are viewing. + def can_access_marketplace? + marketplace_baseline_capable? && marketplace_visible_to_user? + end + + # The two peer baseline capabilities for the marketplace. Either qualifies; the allow-list narrows. + def marketplace_baseline_capable? + user&.course_manager_or_owner? || user&.instance_instructor_or_administrator? + end + + # Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`). + # When the allow-list is retired this whole method is deleted; the block check goes with it. + def marketplace_visible_to_user? + return true if user&.administrator? + + Course::Assessment::Marketplace::AllowlistRule.grants_access?(user) && + !Course::Assessment::Marketplace::AccessBlock.blocked?(user) + end + + def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end @@ -23,4 +57,4 @@ def allow_managers_access_marketplace assessment.marketplace_listing&.published? || false end end -end +end \ No newline at end of file diff --git a/app/models/course/assessment/marketplace/access_block.rb b/app/models/course/assessment/marketplace/access_block.rb new file mode 100644 index 00000000000..03841d618a6 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_block.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AccessBlock < ApplicationRecord + belongs_to :user, inverse_of: false + belongs_to :creator, class_name: 'User', inverse_of: false + + # Paired with the DB unique index on user_id: a user is blocked at most once. + validates :user_id, uniqueness: true + + # Whether +user+ has been individually disabled from the marketplace. Global (not tenant-scoped), + # mirroring AllowlistRule. + # @param [User] user + # @return [Boolean] + def self.blocked?(user) + return false unless user + + where(user_id: user.id).exists? + end + + # @return [Array] user ids of every block (for per-page status annotation). + def self.blocked_user_ids + pluck(:user_id) + end +end diff --git a/app/models/course/assessment/marketplace/access_list_query.rb b/app/models/course/assessment/marketplace/access_list_query.rb new file mode 100644 index 00000000000..d0480acc479 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_list_query.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +# Computes the marketplace access audit list: every user who is baseline-capable (manages/owns >=1 +# course, OR is an instructor/administrator in any instance) AND cleared by the allow-list, PLUS +# every individually blocked user regardless of rule match — an orphaned block must stay visible and +# clearable. Blocked users are INCLUDED and flagged. Not paginated server-side - the eligible set is +# bounded (managers + instance staff) and the frontend paginates/searches client-side, matching the +# rules page which also fetches its whole list at once. +class Course::Assessment::Marketplace::AccessListQuery + AllowlistRule = Course::Assessment::Marketplace::AllowlistRule + AccessBlock = Course::Assessment::Marketplace::AccessBlock + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + # `allowed_by_rules` holds EVERY rule matching the user, not one precedence winner: the admin uses + # it to answer "if I delete this rule, who loses access?", and one reason answers that wrongly. + Row = Struct.new(:user, :course_count, :instance_role, :allowed_by_rules, :block_id, + :system_admin, keyword_init: true) do + def blocked? + block_id.present? + end + + def system_admin? + system_admin.present? + end + end + + # @return [Array] + def rows + @rows ||= annotate(listed_users.to_a) + end + + # @return [Hash] + def summary + { + total_with_access: rows.count { |row| !row.blocked? }, + total_blocked: rows.count(&:blocked?), + open_to_everyone: everyone? + } + end + + # Baseline-eligible users the allow-list currently clears. A block does not remove someone from + # this set — being blocked is a separate decision layered on top of being allowed. + # @return [Set] + def allowed_user_ids + # System admins hold a blanket `can :manage, :all`, so they have the marketplace whatever the + # rules say. This table is the audit source of truth, so they are always in it — omitting them + # would show an admin as having no access while they in fact bypass every gate. + @allowed_user_ids ||= (everyone? ? baseline_ids.to_set : rules_by_user.keys.to_set) | admin_ids + end + + private + + # Batches every per-user annotation into one query each, keyed by user id. + def annotate(users) + ids = users.map(&:id) + counts = managed_course_counts(ids) + staff = instance_staff_roles(ids) + block_ids = block_ids_by_user(ids) + + users.map do |user| + Row.new(user: user, course_count: counts[user.id] || 0, + instance_role: staff[user.id], allowed_by_rules: rules_by_user[user.id] || [], + block_id: block_ids[user.id], system_admin: admin_ids.include?(user.id)) + end + end + + def managed_course_counts(ids) + CourseUser.managers.where(user_id: ids).group(:user_id).count + end + + def block_ids_by_user(ids) + AccessBlock.where(user_id: ids).pluck(:user_id, :id).to_h + end + + def blocked_ids + @blocked_ids ||= AccessBlock.pluck(:user_id).to_set + end + + def listed_users + User.where(id: (allowed_user_ids | blocked_ids).to_a).includes(:emails).order(:name) + end + + def admin_ids + @admin_ids ||= User.administrator.pluck(:id).to_set + end + + def baseline_ids + @baseline_ids ||= baseline_scope.pluck(:id) + end + + # CourseUser is not tenant-scoped ("any course"); InstanceUser IS, so .unscoped for "any instance". + def baseline_scope + User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))). + or(User.administrator) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def everyone? + return @everyone if defined?(@everyone) + + @everyone = AllowlistRule.rule_type_everyone.exists? + end + + # An `everyone` rule is a page-level mode, not a per-row reason, so it contributes no scoped rules. + def scoped_rules + @scoped_rules ||= if everyone? + [] + else + # `user`/`instance` are read when labelling each row's reasons; preload them + # once here rather than once per rule per row. + AllowlistRule.where.not(rule_type: :everyone). + includes(:user, :instance).order(:id).to_a + end + end + + # user id => [AllowlistRule], every rule matching that user, in rules-table order. + def rules_by_user + @rules_by_user ||= scoped_rules.each_with_object({}) do |rule, map| + RuleMatchQuery.new(rule).user_ids_within(baseline_ids).each do |id| + (map[id] ||= []) << rule + end + end + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/course/assessment/marketplace/allowlist_rule.rb b/app/models/course/assessment/marketplace/allowlist_rule.rb new file mode 100644 index 00000000000..a5c3317f169 --- /dev/null +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord + enum :rule_type, + { user: 0, instance: 1, email_domain: 2, everyone: 3 }, + prefix: true + + belongs_to :user, class_name: 'User', inverse_of: false, optional: true + belongs_to :instance, inverse_of: false, optional: true + + # Transient: the admin form identifies a `user` rule by email (user IDs are not shown anywhere + # in the admin panel). Resolved to the owning user before validation; the stored row keeps the + # `user_id` FK, so the rule means "this person" even if they later change email. + attr_accessor :email + + before_validation :resolve_user_from_email, if: -> { rule_type_user? && email.present? } + + # When an email was supplied, `resolve_user_from_email` reports its own failure; skip the generic + # presence check in that path so the message is exactly "No user with that email." (not a pair). + before_validation :normalize_email_domain, if: :rule_type_email_domain? + + validates :user, presence: true, if: -> { rule_type_user? && email.blank? } + validates :instance, presence: true, if: :rule_type_instance? + validates :email_domain, presence: true, if: :rule_type_email_domain? + + # Identical rules grant nothing extra and make the rules table unreadable. Each is paired with a + # partial unique index. `scope: :rule_type` matters on the unresolved-email path: a user rule + # whose email matched nobody keeps user_id NULL, and Rails checks that as `user_id IS NULL`, + # which matches every instance and email-domain rule — reporting a bogus duplicate on top of the + # real "No user with that email." Scoping confines the check to rules of the same type. + validates :user_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_user? + validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_instance? + validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_email_domain? + # "Everyone" is the widest rule; only one may exist. Paired with a partial unique index. + validates :rule_type, uniqueness: true, if: :rule_type_everyone? + + # Whether the marketplace is visible to +user+ per the allow-list. The rules table itself is + # global (not tenant-scoped), but `user.instance_users` IS tenant-scoped (acts_as_tenant), so + # in a request an `instance` rule matches only while browsing the allow-listed instance — + # it grants that instance's users access *there*, not membership-based access everywhere. + # An `everyone` rule grants every authenticated user (the `nil` guard still excludes anonymous). + # Baseline (manager/owner OR instructor/admin) is checked separately in the ability component. + # @param [User] user + # @return [Boolean] + def self.grants_access?(user) + return false unless user + + rule_type_everyone.exists? || + rule_type_user.where(user_id: user.id).exists? || + rule_type_instance.where(instance_id: user.instance_users.select(:instance_id)).exists? || + email_domain_matches?(user) + end + + # @param [User] user + # @return [Boolean] + def self.email_domain_matches?(user) + domains = user.emails.pluck(:email).filter_map { |e| e.split('@').last&.downcase }.uniq + return false if domains.empty? + + rule_type_email_domain.where('LOWER(email_domain) IN (?)', domains).exists? + end + + private + + # Matching is case-insensitive everywhere, so the stored form is normalized on write. This keeps + # the uniqueness index a plain column comparison instead of a functional LOWER() index. + def normalize_email_domain + self.email_domain = email_domain&.strip&.downcase + end + + def resolve_user_from_email + self.user = User.with_email_addresses([email.strip.downcase]).first + errors.add(:base, 'No user with that email.') if user.nil? + end +end diff --git a/app/models/course/assessment/marketplace/rule_match_query.rb b/app/models/course/assessment/marketplace/rule_match_query.rb new file mode 100644 index 00000000000..9722c373364 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_match_query.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# Which baseline-eligible users does a single allow-list rule match? The rule may be unsaved, so the +# admin can preview a rule's effect before adding it. This is the one place that knows each rule +# type's matching semantics; AccessListQuery and the preview endpoint both go through it. +# +# Deliberately NOT the same as AllowlistRule.grants_access?, which reads the tenant-scoped +# `user.instance_users` at request time. Here `instance` rules match globally via +# InstanceUser.unscoped, because the audit list is not browsing any one instance. +class Course::Assessment::Marketplace::RuleMatchQuery + # @param [Course::Assessment::Marketplace::AllowlistRule] rule persisted or in-memory + def initialize(rule) + @rule = rule + end + + # @param [Array] candidate_user_ids the users to test + # @return [Set] the subset of +candidate_user_ids+ this rule matches + def user_ids_within(candidate_user_ids) + return Set.new if candidate_user_ids.empty? + + case @rule.rule_type + when 'everyone' then candidate_user_ids.to_set + when 'user' then matched_user(candidate_user_ids) + when 'instance' then matched_instance_members(candidate_user_ids) + when 'email_domain' then matched_domain_holders(candidate_user_ids) + else Set.new + end + end + + private + + def matched_user(ids) + (@rule.user_id.present? && ids.include?(@rule.user_id)) ? Set[@rule.user_id] : Set.new + end + + def matched_instance_members(ids) + return Set.new if @rule.instance_id.blank? + + InstanceUser.unscoped.where(user_id: ids, instance_id: @rule.instance_id). + pluck(:user_id).to_set + end + + def matched_domain_holders(ids) + domain = @rule.email_domain&.strip&.downcase + return Set.new if domain.blank? + + User::Email.where.not(confirmed_at: nil).where(user_id: ids). + where('LOWER(SPLIT_PART(email, ?, 2)) = ?', '@', domain). + pluck(:user_id).to_set + end +end diff --git a/app/models/course/assessment/marketplace/rule_preview_query.rb b/app/models/course/assessment/marketplace/rule_preview_query.rb new file mode 100644 index 00000000000..e05485b0bf4 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_preview_query.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true +# Answers "if I add this rule, who gets access?" for a rule that has not been saved, so the admin +# sees the effect before committing. Persists nothing. +class Course::Assessment::Marketplace::RulePreviewQuery + AccessBlock = Course::Assessment::Marketplace::AccessBlock + AccessListQuery = Course::Assessment::Marketplace::AccessListQuery + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + Row = Struct.new(:user, :course_count, :instance_role, :already_has_access, :blocked, + keyword_init: true) + + # @param [Course::Assessment::Marketplace::AllowlistRule] rule an unsaved, valid rule + def initialize(rule) + @rule = rule + @access_list = AccessListQuery.new + end + + # @return [Array] + def rows + @rows ||= annotate(matched_users.to_a) + end + + # @return [Hash] + def summary + { + matched_count: rows.size, + # A rule grants nothing to someone another rule already clears, and nothing at all to a + # blocked user — adding a rule does not unblock anyone. + new_count: rows.count { |row| !row.already_has_access && !row.blocked }, + open_to_everyone: @access_list.summary[:open_to_everyone] + } + end + + private + + def matched_ids + @matched_ids ||= RuleMatchQuery.new(@rule).user_ids_within(baseline_ids) + end + + # Only baseline-eligible staff can ever reach the marketplace, so a rule matching anyone else + # grants nothing and must not be counted. + def baseline_ids + @baseline_ids ||= User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))).pluck(:id) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def matched_users + User.where(id: matched_ids.to_a).includes(:emails).order(:name) + end + + def annotate(users) + ids = users.map(&:id) + counts = CourseUser.managers.where(user_id: ids).group(:user_id).count + staff = instance_staff_roles(ids) + allowed = @access_list.allowed_user_ids + blocked = AccessBlock.where(user_id: ids).pluck(:user_id).to_set + + users.map { |user| build_row(user, counts, staff, allowed, blocked) } + end + + def build_row(user, counts, staff, allowed, blocked) + Row.new(user: user, course_count: counts[user.id] || 0, instance_role: staff[user.id], + already_has_access: allowed.include?(user.id), blocked: blocked.include?(user.id)) + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 75aae3fc495..be7e37d6790 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,22 @@ def deleted has_one :cikgo_user, dependent: :destroy, inverse_of: :user + # Both tables FK to users with no ON DELETE, so without these the admin panel's delete-user + # action dies with PG::ForeignKeyViolation for anyone who is allow-listed or blocked. Destroying + # is the right semantic for both: each row is *about* this user and means nothing without them. + has_many :marketplace_allowlist_rules, class_name: 'Course::Assessment::Marketplace::AllowlistRule', + inverse_of: false, dependent: :destroy + has_many :marketplace_access_blocks, class_name: 'Course::Assessment::Marketplace::AccessBlock', + inverse_of: false, dependent: :destroy + # Blocks this user ISSUED. Not `dependent:` anything — destroying them would silently restore + # marketplace access for everyone this admin ever blocked, and `creator_id` is NOT NULL so it + # cannot be nullified either. `reassign_issued_marketplace_blocks` hands authorship to the + # Deleted user instead, which keeps the block standing and satisfies the FK. + has_many :issued_marketplace_access_blocks, class_name: 'Course::Assessment::Marketplace::AccessBlock', + foreign_key: :creator_id, inverse_of: false, + dependent: nil + before_destroy :reassign_issued_marketplace_blocks + accepts_nested_attributes_for :emails scope :ordered_by_name, -> { order(:name) } @@ -96,6 +112,26 @@ def built_in? id == User::SYSTEM_USER_ID || id == User::DELETED_USER_ID end + # Whether the user manages or owns at least one course, in any instance. This is the baseline + # capability for the assessment marketplace: browsing is then further gated by the allow-list. + # `course_users` is not tenant-scoped (CourseUser has no acts_as_tenant), so this correctly + # spans all instances. + # + # @return [Boolean] + def course_manager_or_owner? + course_users.managers.exists? + end + + # Whether the user is an instructor or administrator InstanceUser in ANY instance. This is the + # second baseline capability for the assessment marketplace, a peer of course_manager_or_owner?. + # `instance_users` IS tenant-scoped (acts_as_tenant), so bypass the tenant to span all instances. + # + # @return [Boolean] + def instance_instructor_or_administrator? + ActsAsTenant.without_tenant do + instance_users.where(role: [:instructor, :administrator]).exists? + end + end # Pick the default email and set it as primary email. This method would immediately set the # attributes in the database. # @@ -136,6 +172,14 @@ def build_course_user_from_invitation(invitation) private + # Hands any marketplace blocks this user issued to the Deleted user, so destroying an admin does + # not lift the blocks they put in place (nor trip the NOT NULL FK on `creator_id`). + def reassign_issued_marketplace_blocks + return if id == User::DELETED_USER_ID + + issued_marketplace_access_blocks.update_all(creator_id: User::DELETED_USER_ID) + end + # Gets the default email address record. # # @return [User::Email] The user's primary email address record. diff --git a/app/views/system/admin/marketplace_access/index.json.jbuilder b/app/views/system/admin/marketplace_access/index.json.jbuilder new file mode 100644 index 00000000000..332f37027eb --- /dev/null +++ b/app/views/system/admin/marketplace_access/index.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.allowedByRules row.allowed_by_rules do |rule| + json.id rule.id + json.ruleType rule.rule_type + json.labelValue marketplace_rule_label_value(rule) + end + json.systemAdmin row.system_admin? + json.blocked row.blocked? + json.blockId row.block_id +end + +json.summary do + json.totalWithAccess @summary[:total_with_access] + json.totalBlocked @summary[:total_blocked] + json.openToEveryone @summary[:open_to_everyone] +end diff --git a/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder new file mode 100644 index 00000000000..d05e2b8ec38 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.id rule.id +json.ruleType rule.rule_type +json.userId rule.user_id +json.userName rule.user&.name +json.userEmail rule.user&.email +json.instanceId rule.instance_id +json.instanceName rule.instance&.name +json.emailDomain rule.email_domain diff --git a/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder new file mode 100644 index 00000000000..2b07f8a2ad8 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +json.rules @allowlist_rules do |rule| + json.partial! 'rule', rule: rule +end +json.everyoneRuleId @everyone_rule&.id diff --git a/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder new file mode 100644 index 00000000000..b2c6d535dfe --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true +json.matchedCount @summary[:matched_count] +json.newCount @summary[:new_count] +json.openToEveryone @summary[:open_to_everyone] + +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.alreadyHasAccess row.already_has_access + json.blocked row.blocked +end \ No newline at end of file diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 40eac58adc4..cd144072edb 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -5,6 +5,14 @@ import { } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; import { InstanceListData, InstancePermissions } from 'types/system/instances'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; +import { + AllowlistRulePreviewData, + MarketplaceAccessData, +} from 'types/system/marketplaceAccess'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -173,4 +181,104 @@ export default class AdminAPI extends BaseSystemAPI { getDeploymentInfo(): Promise> { return this.client.get(`${AdminAPI.#urlPrefix}/deployment_info`); } + + /** + * Fetches the marketplace allow-list rules. + */ + indexMarketplaceAllowlistRules(): Promise< + AxiosResponse<{ rules: AllowlistRuleData[]; everyoneRuleId: number | null }> + > { + return this.client.get( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + ); + } + + /** + * Creates a marketplace allow-list rule. + */ + createMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Dry run for a prospective allow-list rule: reports who it would let in, without saving it. + * Runs the same validations as create, so a duplicate rule is reported here as a 400. + */ + previewMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/preview`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Opens the marketplace to everyone by creating the single `everyone` allow-list rule. + * Returns the created rule; only its `id` is consumed (to later restrict). + */ + openMarketplaceToEveryone(): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { allowlist_rule: { rule_type: 'everyone' } }, + ); + } + + /** + * Deletes a marketplace allow-list rule. + */ + deleteMarketplaceAllowlistRule(id: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`, + ); + } + + /** + * Fetches the marketplace access audit list (everyone with effective access, blocked flagged). + */ + indexMarketplaceAccess(): Promise> { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_access`); + } + + /** + * Blocks (disables) a user's marketplace access. Returns the created block's id. + */ + blockMarketplaceUser( + userId: number, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks`, + { + user_id: userId, + }, + ); + } + + /** + * Removes a block, re-enabling the user's marketplace access. + */ + unblockMarketplaceUser(blockId: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks/${blockId}`, + ); + } } diff --git a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx index eebc06c9489..09170daaecd 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DestinationCourseSelector/InstanceDropdown.tsx @@ -7,10 +7,12 @@ import { } from 'react-hook-form'; import { defineMessages, FormattedMessage } from 'react-intl'; import MyLocation from '@mui/icons-material/MyLocation'; -import { Autocomplete, Box, IconButton, Tooltip } from '@mui/material'; +import { IconButton, Tooltip } from '@mui/material'; import { selectDestinationInstances } from 'course/duplication/selectors'; -import TextField from 'lib/components/core/fields/TextField'; +import InstanceAutocomplete, { + InstanceOption, +} from 'lib/components/core/fields/InstanceAutocomplete'; import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -37,59 +39,45 @@ const translations = defineMessages({ const InstanceDropdown: FC = (props) => { const { currentInstanceId, disabled, field, fieldState, setValue } = props; const instances = useAppSelector(selectDestinationInstances); - const instanceIds = useMemo( + const options = useMemo( () => - Object.keys(instances).toSorted( - (a, b) => instances[a].weight - instances[b].weight, - ), + Object.keys(instances) + .toSorted((a, b) => instances[a].weight - instances[b].weight) + .map((id) => ({ + id: parseInt(id, 10), + name: instances[id]?.name ?? '', + })), [instances], ); const { t } = useTranslation(); return (
- - instances[instanceId]?.name ?? '' + - field.onChange(parseInt(instanceId, 10)) - } - options={instanceIds} - renderInput={(inputProps): JSX.Element => ( - } - required - variant="standard" - /> - )} - renderOption={(optionProps, instanceId): JSX.Element => ( - - {instances[instanceId]?.name ?? ''} - - )} - value={field.value?.toString()} + label={} + onBlur={field.onBlur} + onChange={(instanceId): void => field.onChange(instanceId)} + options={options} + required + value={field.value ?? null} />
+ onClick={(): void => setValue('destination_instance_id', currentInstanceId) } > diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index c445a5b9d15..ddb5875f3c7 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -5,6 +5,7 @@ import { Category, Chat, Group, + Storefront, } from '@mui/icons-material'; import useTranslation from 'lib/hooks/useTranslation'; @@ -32,6 +33,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.getHelp', defaultMessage: 'Get Help', }, + marketplace: { + id: 'system.admin.admin.AdminNavigator.marketplace', + defaultMessage: 'Marketplace Access', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -64,6 +69,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.courses), path: '/admin/courses', }, + { + icon: , + title: t(translations.marketplace), + path: '/admin/marketplace_allowlist_rules', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx new file mode 100644 index 00000000000..f450aa2ba73 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { FilterList } from '@mui/icons-material'; +import { + Badge, + Button, + Checkbox, + Divider, + FormControlLabel, + IconButton, + Menu, + Tooltip, + Typography, +} from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +export interface RuleOption { + id: number; + label: string; +} + +interface Props { + showActive: boolean; + showBlocked: boolean; + onToggleActive: () => void; + onToggleBlocked: () => void; + /** Empty when the marketplace is open to everyone — the rule group is then meaningless. */ + ruleOptions: RuleOption[]; + /** + * Ids the admin has UNchecked. Tracking exclusions rather than inclusions means a newly added + * rule is filtered in by default, with no state to resynchronise when `ruleOptions` changes. + */ + uncheckedRuleIds: Set; + onToggleRule: (id: number) => void; + onClear: () => void; +} + +const translations = defineMessages({ + trigger: { + id: 'system.admin.admin.MarketplaceAccessFilter.trigger', + defaultMessage: 'Filter', + }, + status: { + id: 'system.admin.admin.MarketplaceAccessFilter.status', + defaultMessage: 'Status', + }, + active: { + id: 'system.admin.admin.MarketplaceAccessFilter.active', + defaultMessage: 'Active', + }, + blocked: { + id: 'system.admin.admin.MarketplaceAccessFilter.blocked', + defaultMessage: 'Blocked', + }, + allowedByRule: { + id: 'system.admin.admin.MarketplaceAccessFilter.allowedByRule', + defaultMessage: 'Allowed by rule', + }, + clearAll: { + id: 'system.admin.admin.MarketplaceAccessFilter.clearAll', + defaultMessage: 'Clear all', + }, +}); + +/** + * A bespoke filter popover rather than the shared table's built-in per-column filtering + * (`filterable` + `filterProps`). The built-in machinery could in fact handle both the array-valued + * rules column (via `filterProps.getValue`/`shouldInclude`) and the synthetic System-admin option + * (`getValue` is arbitrary) — those two objections are false. The real reason is that built-in + * filtering is table-internal in the three respects this feature needs externalised: + * + * 1. The filtered result never leaves the table. `TableTemplate` exposes no callback for it, and + * the count feeds pagination internally — yet the section renders a + * "Filtered: N with access · M blocked" line that needs the filtered set outside the table. + * (Decisive.) + * 2. Render location. `MuiFilterMenu` renders inside a column header; the design is one filter + * icon in the toolbar spanning Status AND rules, with a single badge and one "Clear all" — + * built-in yields two header icons, two badges, two independent clears. + * 3. Checked-by-default is unreachable. In the built-in filter the selection array IS the filter, + * so a both-on Status default would need the selection inverted, putting checkmarks on exactly + * the wrong items. This component instead tracks EXCLUSIONS, so a newly added rule filters in + * by default with no state to resynchronise. + */ +const MarketplaceAccessFilter = ({ + showActive, + showBlocked, + onToggleActive, + onToggleBlocked, + ruleOptions, + uncheckedRuleIds, + onToggleRule, + onClear, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [anchor, setAnchor] = useState(null); + + const activeCount = + (showActive ? 0 : 1) + (showBlocked ? 0 : 1) + uncheckedRuleIds.size; + + const label = t(translations.trigger); + + return ( + <> + + + setAnchor(event.currentTarget)} + > + + + + + + setAnchor(null)} + open={Boolean(anchor)} + > +
+ + {t(translations.status)} + + + + } + label={t(translations.active)} + /> + + + } + label={t(translations.blocked)} + /> + + {ruleOptions.length > 0 && ( + <> + + + + {t(translations.allowedByRule)} + + + {ruleOptions.map((option) => ( + onToggleRule(option.id)} + /> + } + label={option.label} + /> + ))} + + )} + + +
+
+ + ); +}; + +export default MarketplaceAccessFilter; diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx new file mode 100644 index 00000000000..e893af73ee3 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx @@ -0,0 +1,639 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button, Chip, Typography } from '@mui/material'; +import { + AllowedByRule, + MarketplaceAccessUser, +} from 'types/system/marketplaceAccess'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MarketplaceAccessFilter, { RuleOption } from './MarketplaceAccessFilter'; + +/** + * Filter id for the synthetic "System admin" option. Negative so it can never collide with a real + * allow-list rule id, which is what the other options carry. + */ +const SYSTEM_ADMIN_OPTION_ID = -1; + +interface Props { + /** Owned by the page, not this section: the toggle and this list must never disagree. */ + openToEveryone: boolean; + /** Bumped by the page on every rule mutation; a change refetches the list. */ + ruleVersion: number; + /** The page's current scoped rules, used to label the filter's rule checkboxes. */ + rules: AllowlistRuleData[]; + /** + * Published after each fetch: rule id => number of listed users that rule grants access to. The + * rules table above the section consumes it to flag rules that match nobody. A rule granting zero + * people contributes no key, so a zero-match rule is simply absent from the map. + */ + onMatchCounts?: (counts: Map) => void; +} + +const translations = defineMessages({ + heading: { + id: 'system.admin.admin.MarketplaceAccessSection.heading', + defaultMessage: 'People with access', + }, + summary: { + id: 'system.admin.admin.MarketplaceAccessSection.summary', + defaultMessage: 'Total with access: {count} · {mode}', + }, + summaryWithBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.summaryWithBlocked', + defaultMessage: + 'Total with access: {count} · Total blocked: {blocked} · {mode}', + }, + filteredCounts: { + id: 'system.admin.admin.MarketplaceAccessSection.filteredCounts', + defaultMessage: 'Filtered: {count} with access · {blocked} blocked', + }, + modeOpen: { + id: 'system.admin.admin.MarketplaceAccessSection.modeOpen', + defaultMessage: 'Open to everyone', + }, + modeScoped: { + id: 'system.admin.admin.MarketplaceAccessSection.modeScoped', + defaultMessage: 'Scoped to the rules above', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.fetchFailure', + defaultMessage: 'Failed to load the marketplace access list.', + }, + colName: { + id: 'system.admin.admin.MarketplaceAccessSection.colName', + defaultMessage: 'Name', + }, + colEmail: { + id: 'system.admin.admin.MarketplaceAccessSection.colEmail', + defaultMessage: 'Email', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAccessSection.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colAllowedBy: { + id: 'system.admin.admin.MarketplaceAccessSection.colAllowedBy', + defaultMessage: 'Allowed by', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAccessSection.colStatus', + defaultMessage: 'Status', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAccessSection.colActions', + defaultMessage: 'Actions', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAccessSection.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + instanceInstructor: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceInstructor', + defaultMessage: 'Instance instructor', + }, + instanceAdministrator: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceAdministrator', + defaultMessage: 'Instance administrator', + }, + allowedEveryone: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedEveryone', + defaultMessage: 'Everyone', + }, + allowedNothing: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedNothing', + defaultMessage: 'No matching rule', + }, + systemAdmin: { + id: 'system.admin.admin.MarketplaceAccessSection.systemAdmin', + defaultMessage: 'System admin', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAccessSection.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAccessSection.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAccessSection.typeEmailDomain', + defaultMessage: 'Email domain', + }, + statusActive: { + id: 'system.admin.admin.MarketplaceAccessSection.statusActive', + defaultMessage: 'Active', + }, + statusBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.statusBlocked', + defaultMessage: 'Blocked', + }, + disable: { + id: 'system.admin.admin.MarketplaceAccessSection.disable', + defaultMessage: 'Block', + }, + reEnable: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnable', + defaultMessage: 'Unblock', + }, + disableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.disableSuccess', + defaultMessage: 'Access blocked for this user.', + }, + disableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.disableFailure', + defaultMessage: 'Failed to block access.', + }, + reEnableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableSuccess', + defaultMessage: 'Access unblocked for this user.', + }, + reEnableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableFailure', + defaultMessage: 'Failed to unblock access.', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAccessSection.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, + dormantHeading: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantHeading', + defaultMessage: 'Dormant blocks ({count})', + }, + dormantExplanation: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantExplanation', + defaultMessage: + 'These people are blocked but no rule currently grants them access. The block denies ' + + 'nothing today — but it would take effect again if a rule starts matching them, so clear ' + + 'it if it is no longer wanted.', + }, + clearBlock: { + id: 'system.admin.admin.MarketplaceAccessSection.clearBlock', + defaultMessage: 'Clear block', + }, +}); + +const MarketplaceAccessSection = ({ + openToEveryone, + ruleVersion, + rules, + onMatchCounts, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [users, setUsers] = useState([]); + const [showActive, setShowActive] = useState(true); + const [showBlocked, setShowBlocked] = useState(true); + const [uncheckedRuleIds, setUncheckedRuleIds] = useState>( + new Set(), + ); + + useEffect(() => { + let cancelled = false; + setIsRefreshing(true); + + SystemAPI.admin + .indexMarketplaceAccess() + .then((response) => { + if (cancelled) return; + setUsers(response.data.users); + // Publish per-rule grant counts for the rules table above. Built from rows, so a rule that + // grants access to nobody contributes no key at all — its absence is the zero-match signal. + const counts = new Map(); + response.data.users.forEach((user) => { + user.allowedByRules.forEach((rule) => { + counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1); + }); + }); + onMatchCounts?.(counts); + }) + .catch(() => toast.error(t(translations.fetchFailure))) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + setIsRefreshing(false); + }); + + return () => { + cancelled = true; + }; + }, [ruleVersion]); + + const handleDisable = async (user: MarketplaceAccessUser): Promise => { + try { + const response = await SystemAPI.admin.blockMarketplaceUser(user.id); + setUsers((current) => + current.map((u) => + u.id === user.id + ? { ...u, blocked: true, blockId: response.data.id } + : u, + ), + ); + toast.success(t(translations.disableSuccess)); + } catch { + toast.error(t(translations.disableFailure)); + } + }; + + /** + * Whether anything currently grants this person access, ignoring any block. Mirrors the server's + * own notion of "allowed": the role, the everyone-mode, or at least one matching rule. Note that + * everyone-mode deliberately sends no per-row rules, so an empty `allowedByRules` is NOT on its + * own a signal that someone has no access. + */ + const isAllowed = (user: MarketplaceAccessUser): boolean => + user.systemAdmin || openToEveryone || user.allowedByRules.length > 0; + + /** Blocked, but nothing would grant them access anyway — the block denies nothing today. */ + const isDormantBlock = (user: MarketplaceAccessUser): boolean => + user.blocked && !isAllowed(user); + + const handleReEnable = async (user: MarketplaceAccessUser): Promise => { + if (user.blockId === null) return; + try { + await SystemAPI.admin.unblockMarketplaceUser(user.blockId); + setUsers((current) => + // Someone listed ONLY because they were blocked has no reason to stay once the block goes — + // patching the row in place would leave them as "Active · No matching rule", counted as + // having access they do not have. Mirrors the server: listed iff allowed OR blocked. + current.flatMap((u) => { + if (u.id !== user.id) return [u]; + return isAllowed(u) ? [{ ...u, blocked: false, blockId: null }] : []; + }), + ); + toast.success(t(translations.reEnableSuccess)); + } catch { + toast.error(t(translations.reEnableFailure)); + } + }; + + const eligibleVia = (user: MarketplaceAccessUser): string => { + // A system admin's eligibility comes from the role, not from courses or instance membership — + // and they are listed even when they have neither, where the other branches say nothing. + if (user.systemAdmin) return t(translations.systemAdmin); + + const parts: string[] = []; + if (user.courseCount > 0) { + parts.push(t(translations.managesCourses, { count: user.courseCount })); + } + if (user.instanceRole === 'instructor') { + parts.push(t(translations.instanceInstructor)); + } + if (user.instanceRole === 'administrator') { + parts.push(t(translations.instanceAdministrator)); + } + return parts.length > 0 ? parts.join('; ') : '—'; + }; + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const ruleLabel = (rule: AllowedByRule): string => + `${typeLabels[rule.ruleType]} (${rule.labelValue ?? `#${rule.id}`})`; + + // Every reason, not one winner: the admin reads this column to decide which rules are safe to + // delete, and a single reason answers that question wrongly. + const renderAllowedBy = (user: MarketplaceAccessUser): JSX.Element => { + // Ahead of both other branches: the role is why they have access, and it outlives any rule + // change — saying "Everyone" or naming a rule would misattribute it. + if (user.systemAdmin) return {t(translations.systemAdmin)}; + if (openToEveryone) return {t(translations.allowedEveryone)}; + if (user.allowedByRules.length === 0) { + return {t(translations.allowedNothing)}; + } + + return ( +
+ {user.allowedByRules.map((rule) => ( + {ruleLabel(rule)} + ))} +
+ ); + }; + + const ruleOptionLabel = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'user': + return `${typeLabels.user} (${rule.userName ?? `#${rule.userId}`})`; + case 'instance': + return `${typeLabels.instance} (${ + rule.instanceName ?? `#${rule.instanceId}` + })`; + default: + return `${typeLabels.email_domain} (${rule.emailDomain ?? ''})`; + } + }; + + // Open to everyone means every row is granted by the mode, not by a rule, so the group is hidden. + // System admin is a reason in its own right, so it gets an option whenever any listed user is + // one — including in everyone-mode, where their access still comes from the role, not the mode. + const ruleOptions: RuleOption[] = [ + ...(users.some((user) => user.systemAdmin) + ? [{ id: SYSTEM_ADMIN_OPTION_ID, label: t(translations.systemAdmin) }] + : []), + ...(openToEveryone + ? [] + : rules.map((rule) => ({ id: rule.id, label: ruleOptionLabel(rule) }))), + ]; + + const toggleRule = (id: number): void => + setUncheckedRuleIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const clearFilters = (): void => { + setShowActive(true); + setShowBlocked(true); + setUncheckedRuleIds(new Set()); + }; + + const matchesFilter = (user: MarketplaceAccessUser): boolean => { + if (user.blocked ? !showBlocked : !showActive) return false; + if (uncheckedRuleIds.size === 0) return true; + + // Being a system admin is a reason alongside the rules, so an admin survives the filter while + // that option stays checked — without this they carry no reasons at all and would vanish the + // moment any rule box is unchecked. + const reasonIds = user.allowedByRules.map((rule) => rule.id); + if (user.systemAdmin) reasonIds.push(SYSTEM_ADMIN_OPTION_ID); + // Everyone-mode grants access outside the rules, so only the admin option can filter there. + if (openToEveryone && !user.systemAdmin) return true; + + return reasonIds.some((id) => !uncheckedRuleIds.has(id)); + }; + + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + searchable: true, + cell: (user) => user.email, + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => eligibleVia(user), + }, + { + id: 'allowedBy', + title: t(translations.colAllowedBy), + cell: (user) => renderAllowedBy(user), + }, + { + id: 'status', + title: t(translations.colStatus), + // This column's content changes with row STATE (Active↔Blocked), so its intrinsic width + // changes as people are blocked, shifting every column to its left. A width on the cell + // itself does NOT fix that: under table-layout:auto a cell width is only a suggestion, and + // the browser still distributes slack using each column's max-content width. Pinning the + // width on a wrapper INSIDE the cell makes that max-content constant, which is what actually + // holds the layout still. `whitespace-nowrap` keeps an overlong translation overflowing + // visibly rather than wrapping and silently reintroducing the shift. + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + { + id: 'action', + title: t(translations.colActions), + // Same reasoning as `status` above: Block↔Unblock. Sized to `Unblock`, the wider of the + // two, so flipping a row never moves its neighbours. + className: 'whitespace-nowrap', + cell: (user) => + // No action for a system admin: `can :manage, :all` outranks the allow-list, so a block + // would not actually revoke anything — the row would read "Blocked" while they kept full + // access. Better to offer nothing than an action that silently does nothing. + user.systemAdmin ? null : ( +
+ {/* + `min-w-0 px-0` on both: MUI gives a Button horizontal padding and a 64px min-width, so + the label sits inset from the cell edge (misaligned with the `Actions` header) by an + amount that DIFFERS per label — `Block` is narrower than the min-width and gets + centred in the leftover space, `Unblock` is not. Stripping both makes the button hug + its text, so header and both states start at the same x. + */} + {user.blocked ? ( + + ) : ( + + )} +
+ ), + }, + ]; + + // No status or reason columns: every row here is dormant-blocked and allowed by nothing, so + // those cells would repeat the section heading on every line. + const dormantColumns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + cell: (user) => user.email, + }, + { + id: 'action', + title: t(translations.colActions), + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + ]; + + if (isLoading) return ; + + // Derived from the rows, not the server summary: block/unblock patch rows locally without a + // refetch, so a summary-bound count would drift the moment an admin disables someone. + // Split first: a dormant block is not a person with access, so it is counted out of the headline + // totals and out of the main table, and gets its own section below. + const dormantUsers = users.filter(isDormantBlock); + const accessUsers = users.filter((user) => !isDormantBlock(user)); + const totalWithAccess = accessUsers.filter((user) => !user.blocked).length; + const totalBlocked = accessUsers.filter((user) => user.blocked).length; + const filteredUsers = accessUsers.filter(matchesFilter); + // Only the filter menu is observable here — the search box lives inside Table and narrows the + // rows after this point, so a search alone does not surface the line. + const isFiltered = filteredUsers.length < accessUsers.length; + const mode = openToEveryone + ? t(translations.modeOpen) + : t(translations.modeScoped); + + // Remount the main table when the filter state changes so pagination snaps back to the first + // page: an admin on page 2 who narrows the filter below one page of results would otherwise be + // stranded on an empty page. The shared Table keeps pagination internal with no external setter + // and does not auto-reset the page index on a data change, so a key change is the only in-section + // lever. Keyed on the filter state alone (not the fetched data), so a background refetch does not + // disturb the current page. The dormant table below is unfiltered and needs none of this. + const filterKey = `${showActive}:${showBlocked}:${[...uncheckedRuleIds] + .sort((a, b) => a - b) + .join(',')}`; + + return ( +
+ {t(translations.heading)} + + + {totalBlocked > 0 + ? t(translations.summaryWithBlocked, { + count: totalWithAccess, + blocked: totalBlocked, + mode, + }) + : t(translations.summary, { count: totalWithAccess, mode })} + + + {/* + Only while the filter is narrowing: unfiltered, this line would repeat the totals verbatim. + The totals above stay put as the audit anchor — this answers the narrower question the + filter poses ("of the people this rule lets in, how many are blocked?"), which nothing else + on the page reports. + */} + {isFiltered && ( + + {t(translations.filteredCounts, { + count: filteredUsers.filter((user) => !user.blocked).length, + blocked: filteredUsers.filter((user) => user.blocked).length, + })} + + )} + +
+ user.id.toString()} + pagination={{ + initialPageSize: 20, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + toolbar={{ + show: true, + buttons: [ + setShowActive((on) => !on)} + onToggleBlocked={(): void => setShowBlocked((on) => !on)} + onToggleRule={toggleRule} + ruleOptions={ruleOptions} + showActive={showActive} + showBlocked={showBlocked} + uncheckedRuleIds={uncheckedRuleIds} + />, + ], + }} + /> + + + {dormantUsers.length > 0 && ( +
+ + {t(translations.dormantHeading, { count: dormantUsers.length })} + + + + {t(translations.dormantExplanation)} + + +
+
user.id.toString()} + pagination={{ + initialPageSize: 10, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + }} + /> + + + )} + + ); +}; + +export default MarketplaceAccessSection; diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx new file mode 100644 index 00000000000..d2a218286e1 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, FormControlLabel, Switch, Typography } from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + openToEveryone: boolean; + onOpenToEveryone: () => Promise; + onRestrict: () => Promise; +} + +const translations = defineMessages({ + scopedTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle', + defaultMessage: 'Access is limited to the rules below.', + }, + everyoneTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle', + defaultMessage: + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + }, + toggleLabel: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel', + defaultMessage: 'Open to everyone', + }, + openConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle', + defaultMessage: 'Open marketplace to everyone?', + }, + openConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody', + defaultMessage: + 'This makes the marketplace visible to all eligible staff: course managers/owners and instance instructors/administrators. You can restrict it again at any time; your scoped rules are kept.', + }, + restrictConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle', + defaultMessage: 'Restrict to scoped rules?', + }, + restrictConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody', + defaultMessage: + 'The marketplace will again be limited to the rules below. Eligible staff not covered by a rule will lose access.', + }, + confirmOpen: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen', + defaultMessage: 'Open to everyone', + }, + confirmRestrict: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict', + defaultMessage: 'Restrict', + }, +}); + +const MarketplaceAllowlistModeBanner = ({ + openToEveryone, + onOpenToEveryone, + onRestrict, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async (): Promise => { + setSubmitting(true); + try { + await (openToEveryone ? onRestrict() : onOpenToEveryone()); + setIsConfirmOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + setIsConfirmOpen(true)} + /> + } + label={ + + {t(translations.toggleLabel)} + + } + labelPlacement="start" + sx={{ mr: 1 }} + /> + } + className="mb-4 [&_.MuiAlert-action]:items-center [&_.MuiAlert-action]:pt-0" + severity={openToEveryone ? 'success' : 'info'} + > + {openToEveryone + ? t(translations.everyoneTitle) + : t(translations.scopedTitle)} + + + setIsConfirmOpen(false)} + open={isConfirmOpen} + primaryColor={openToEveryone ? 'error' : 'primary'} + primaryDisabled={submitting} + primaryLabel={ + openToEveryone + ? t(translations.confirmRestrict) + : t(translations.confirmOpen) + } + title={ + openToEveryone + ? t(translations.restrictConfirmTitle) + : t(translations.openConfirmTitle) + } + > + {openToEveryone + ? t(translations.restrictConfirmBody) + : t(translations.openConfirmBody)} + + + ); +}; + +export default MarketplaceAllowlistModeBanner; diff --git a/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx new file mode 100644 index 00000000000..8a865a50e8f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx @@ -0,0 +1,949 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import SystemAPI from 'api/system'; + +import MarketplaceAccessSection from '../MarketplaceAccessSection'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const ACCESS_URL = '/admin/marketplace_access'; +const BLOCKS_URL = '/admin/marketplace_access_blocks'; + +const activeUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 3, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: false, + blockId: null, +}; + +/** Blocked AND still allowed by a rule — a LIVE block, so they belong in the main table. */ +const blockedUser = { + id: 2, + name: 'Kumar Raj', + email: 'kumar@sch.edu.sg', + courseCount: 0, + instanceRole: 'instructor' as const, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: true, + blockId: 55, +}; + +/** + * Blocked with nothing granting them access — their rule was deleted while the block stood. The + * block denies nothing today, so this one belongs in the dormant section, not the main table. + */ +const dormantUser = { + id: 4, + name: 'Dormant Dan', + email: 'dan@sch.edu.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [], + systemAdmin: false, + blocked: true, + blockId: 77, +}; + +const adminUser = { + id: 3, + name: 'Root Admin', + email: 'root@coursemology.org', + courseCount: 0, + instanceRole: null, + allowedByRules: [], + systemAdmin: true, + blocked: false, + blockId: null, +}; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: 'nus.edu.sg', +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 1, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const renderSection = (props?: { + openToEveryone?: boolean; + ruleVersion?: number; + rules?: (typeof DOMAIN_RULE | typeof USER_RULE)[]; +}): ReturnType => + render( + , + ); + +const accessGetCount = (): number => + mock.history.get.filter((request) => request.url === ACCESS_URL).length; + +it('renders the access list with annotations and a summary', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); + expect(page.getByText('Manages 3 courses')).toBeVisible(); + expect(page.getByText('Instance instructor')).toBeVisible(); + // Both fixtures are allowed by the same rule, so the label appears once per row. + expect(page.getAllByText('Email domain (nus.edu.sg)')).toHaveLength(2); + expect(page.getByText('Active')).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('names the blocked total in the subtitle when anyone is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('omits the blocked segment when nobody is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('reads the mode from props rather than the fetched summary', async () => { + // The parent owns the toggle, so a stale server summary must not win. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect( + await page.findByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(); +}); + +it('shows Everyone as the reason when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect(await page.findByText('Everyone')).toBeVisible(); + expect(page.queryByText('Email domain (nus.edu.sg)')).not.toBeInTheDocument(); +}); + +it('lists every rule that grants a user access', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Email domain (nus.edu.sg)')).toBeVisible(); + expect(page.getByText('User (Jane Tan)')).toBeVisible(); +}); + +it('moves a block with no matching rule into the dormant list', async () => { + // Their rule was deleted while the block stood. The block denies nothing today, so they are not + // "people with access" — but it must stay visible and clearable, because re-adding a matching + // rule would silently leave them blocked. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.getByText('Dormant blocks (1)')).toBeVisible(); + expect(page.getByText('Dormant Dan')).toBeVisible(); + // Counted out of the headline totals, which describe people with access. + expect( + page.getByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('keeps a block that a rule still backs in the main table', async () => { + // This block IS denying access right now, so it belongs with the people it applies to. + mock.onGet(ACCESS_URL).reply(200, { + users: [blockedUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); + expect( + page.getByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('shows no dormant section when there are no dormant blocks', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('treats a block as dormant only outside everyone-mode', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is not "no access" — + // the block is live and the row stays in the main table. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Dormant Dan'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('clears a dormant block and drops the row', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection(); + await page.findByText('Dormant Dan'); + + fireEvent.click(page.getByRole('button', { name: 'Clear block' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/77`); + + // Nothing grants them access, so clearing the block removes their last reason to be listed. + await waitFor(() => + expect(page.queryByText('Dormant Dan')).not.toBeInTheDocument(), + ); + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('pins the width of the two state-driven columns', async () => { + // Status and Actions are the only columns whose content changes with row STATE + // (Active↔Blocked, Block↔Unblock), so under table-layout:auto they resize as people are + // blocked and shift every column to their left. The width must sit on a wrapper INSIDE the cell, + // not on the cell: a table cell's width is only a suggestion under auto layout, so a cell-level + // class leaves the shift in place. jsdom does no layout, so this asserts the wrapper exists and + // is pinned in BOTH states; the visual claim is covered by manual verification. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Both status states, so a width applied to only one branch of the ternary would fail. + expect(page.getByText('Blocked').closest('div.w-28')).toBeInTheDocument(); + expect(page.getByText('Active').closest('div.w-28')).toBeInTheDocument(); + + // Both action states, for the same reason. + const reEnable = page.getByRole('button', { name: 'Unblock' }); + const disable = page.getByRole('button', { name: 'Block' }); + expect(reEnable.closest('div.w-24')).toBeInTheDocument(); + expect(disable.closest('div.w-24')).toBeInTheDocument(); + + // MUI's button padding and 64px min-width inset each label from the cell edge by a per-label + // amount, so the two states and the column header start at different x without these. + expect(reEnable).toHaveClass('min-w-0', 'px-0'); + expect(disable).toHaveClass('min-w-0', 'px-0'); +}); + +it('labels a system admin in both reason columns', async () => { + // The admin manages nothing and matches no rule, so without the systemAdmin branch these cells + // would read '—' and 'No matching rule' for someone who in fact bypasses every gate. + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + expect(page.getAllByText('System admin')).toHaveLength(2); + expect(page.queryByText('No matching rule')).not.toBeInTheDocument(); +}); + +it('labels a system admin as such even when open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Root Admin'); + + expect(page.getAllByText('System admin')).toHaveLength(2); + expect(page.queryByText('Everyone')).not.toBeInTheDocument(); +}); + +it('reports filtered counts only while the filter narrows the set', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Unfiltered: the line would only repeat the totals, so it is absent. + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(); + + await toggleFilter(page, 'Active'); + + expect( + await page.findByText('Filtered: 0 with access · 1 blocked'), + ).toBeVisible(); + // The totals stay put as the audit anchor rather than being rewritten by the filter. + expect( + page.getByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(), + ); +}); + +it('offers no disable action for a system admin', async () => { + // Blocking an admin cannot revoke anything (`can :manage, :all` outranks the allow-list), so the + // action would be a lie — the row would say "Blocked" while they kept full access. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + // Exactly one Block button, and it belongs to the non-admin. + expect(page.getAllByRole('button', { name: 'Block' })).toHaveLength(1); + expect( + page.queryByRole('button', { name: 'Unblock' }), + ).not.toBeInTheDocument(); +}); + +it('filters system admins in and out via their own option', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + await toggleFilter(page, 'System admin'); + await waitFor(() => + expect(page.queryByText('Root Admin')).not.toBeInTheDocument(), + ); + expect(page.getByText('Jane Tan')).toBeVisible(); + + await toggleFilter(page, 'System admin'); + await waitFor(() => expect(page.getByText('Root Admin')).toBeVisible()); +}); + +it('keeps a system admin listed when a rule box is unchecked', async () => { + // An admin carries no rules, so treating rules as the only reasons would drop them from the + // table the moment any rule filter is touched. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Root Admin'); + + await toggleFilter(page, 'Email domain (nus.edu.sg)'); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Root Admin')).toBeVisible(); +}); + +it('offers no system-admin option when nobody listed is one', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + expect( + page.queryByRole('checkbox', { name: 'System admin' }), + ).not.toBeInTheDocument(); +}); + +it('links each name to that user', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); +}); + +it('refetches the list when the rule version changes', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(await page.findByText('Kumar Raj')).toBeVisible(); +}); + +it('does not refetch when unrelated props change', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => + expect( + page.getByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(), + ); + expect(accessGetCount()).toBe(1); +}); + +it('disables an active user and flips the row to Blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ user_id: 1 }); + + expect(await page.findByRole('button', { name: 'Unblock' })).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('updates the subtitle counts after a local disable, without refetching', async () => { + // Block/unblock patch rows in place, so counts must come from the rows, not the server summary. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + expect( + await page.findByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + expect(accessGetCount()).toBe(1); +}); + +it('re-enables a blocked user and flips the row to Active', async () => { + // A rule still allows them, so unblocking leaves them listed — the row flips rather than going. + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...blockedUser, + allowedByRules: [ + { + id: 10, + ruleType: 'email_domain' as const, + labelValue: 'nus.edu.sg', + }, + ], + }, + ], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/55`).reply(200); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/55`); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText('Active')).toBeVisible(); +}); + +it('keeps an unblocked user listed in everyone-mode, where rules are empty by design', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is NOT a signal that + // they have no access — dropping on empty alone would wrongly remove them here. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], // no rules at all, so only the everyone-mode branch can keep them + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection({ openToEveryone: true }); + await page.findByText('Dormant Dan'); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText('Dormant Dan')).toBeVisible(); +}); + +it('searches by name and email', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'kumar@', + ); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +// Anchor on the menu's role, not on its "Status" heading: the table has a Status column, so +// findByText('Status') matches both the column header and the filter panel. +const openFilter = async (page: ReturnType): Promise => { + fireEvent.click(page.getByRole('button', { name: 'Filter' })); + await page.findByRole('menu'); +}; + +const closeFilter = async (page: ReturnType): Promise => { + // MUI's Menu renders a modal backdrop over the table. Close it before asserting on, or typing + // into, anything underneath — otherwise the assertions race the overlay rather than the filter. + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(page.queryByRole('menu')).not.toBeInTheDocument()); +}; + +/** Open the filter, toggle one checkbox by its accessible name, then close it. */ +const toggleFilter = async ( + page: ReturnType, + checkboxName: string, +): Promise => { + await openFilter(page); + fireEvent.click(page.getByRole('checkbox', { name: checkboxName })); + await closeFilter(page); +}; + +it('shows both active and blocked users by default', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('shows only blocked users when Active is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('filters to the users a specific rule grants access to', async () => { + const otherUser = { + ...activeUser, + id: 3, + name: 'Wei Ling', + email: 'wei@moe.gov.sg', + allowedByRules: [ + { id: 11, ruleType: 'user' as const, labelValue: 'Wei Ling' }, + ], + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, otherUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Jane Tan'); + + // Uncheck the user rule; only the domain-granted user should remain. + await toggleFilter(page, 'User (Jane Tan)'); + + await waitFor(() => + expect(page.queryByText('Wei Ling')).not.toBeInTheDocument(), + ); + // Assert on the email, not the name: 'Jane Tan' is also the user rule's checkbox label, so a + // name query would match two elements whenever the filter menu is open. + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('hides the rule group when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true, rules: [DOMAIN_RULE] }); + await page.findByText('Jane Tan'); + await openFilter(page); + + // Scoped to the menu: the table also has a Status column header. + expect(within(page.getByRole('menu')).getByText('Status')).toBeVisible(); + expect(page.queryByText('Allowed by rule')).not.toBeInTheDocument(); + expect( + page.queryByRole('checkbox', { name: 'Email domain (nus.edu.sg)' }), + ).not.toBeInTheDocument(); +}); + +it('badges the filter button while any box is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + fireEvent.click(page.getByRole('checkbox', { name: 'Active' })); + + // Scope to the badge: a bare '1' would also match pagination and count text. + expect( + await page.findByText('1', { selector: '.MuiBadge-badge' }), + ).toBeVisible(); +}); + +it('composes the filter with the search field', async () => { + const otherBlocked = { + ...blockedUser, + id: 4, + name: 'Siti Nur', + email: 'siti@sch.edu.sg', + blockId: 56, + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser, otherBlocked], + summary: { totalWithAccess: 1, totalBlocked: 2, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'siti', + ); + + await waitFor(() => + expect(page.queryByText('Kumar Raj')).not.toBeInTheDocument(), + ); + expect(page.getByText('Siti Nur')).toBeVisible(); +}); + +it('restores everything when the filter is cleared', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await openFilter(page); + fireEvent.click(page.getByRole('button', { name: 'Clear all' })); + await closeFilter(page); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it("publishes each rule's grant count after the access list loads", async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + blockedUser, // allowedByRules: [rule 10] + ], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + // Rule 10 grants both listed users; rule 11 grants only the first. + expect(counts.get(10)).toBe(2); + expect(counts.get(11)).toBe(1); +}); + +it('omits a rule that grants access to nobody from the published counts', async () => { + // A zero-match rule contributes no key — its absence is what the rules table reads as "nobody". + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // allowedByRules: [rule 10] only + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.has(11)).toBe(false); + expect(counts.get(10)).toBe(1); +}); + +it('republishes counts when the rule version changes', async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // rule 10 grants 1 + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(1)); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], // rule 10 now grants 2 + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(2)); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.get(10)).toBe(2); +}); + +it('returns to the first page when the filter narrows the result set', async () => { + // 21 people are granted by the domain rule and 4 by the user rule, so the list spans two pages at + // the default page size of 20. An admin on page 2 who filters out the domain rule drops to a + // single page — the table must snap back to page 1 rather than strand them on an empty page 2. + const domainUsers = Array.from({ length: 21 }, (_, i) => ({ + id: i + 1, + name: `Domain User ${i + 1}`, + email: `domain${i + 1}@nus.edu.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: 'nus.edu.sg' }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + })); + const userRuleUsers = Array.from({ length: 4 }, (_, i) => ({ + id: 100 + i, + name: `Rule User ${i + 1}`, + email: `rule${i + 1}@moe.gov.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [{ id: 11, ruleType: 'user', labelValue: 'Jane Tan' }], + systemAdmin: false, + blocked: false, + blockId: null, + })); + mock.onGet(ACCESS_URL).reply(200, { + users: [...domainUsers, ...userRuleUsers], + summary: { totalWithAccess: 25, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Domain User 1'); + + // Go to page 2 — the four user-rule people live here, past the first 20 domain users. + fireEvent.click(page.getByRole('button', { name: 'Go to next page' })); + await page.findByText('Rule User 1'); + expect(page.queryByText('Domain User 1')).not.toBeInTheDocument(); + + // Filter out the domain rule: only the four user-rule people remain — a single page. + await toggleFilter(page, 'Email domain (nus.edu.sg)'); + + // Snapped back to page 1: the remaining people are visible, not stranded behind an empty page 2. + expect(await page.findByText('Rule User 1')).toBeVisible(); + expect(page.getByText('Rule User 4')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx new file mode 100644 index 00000000000..433926647a3 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx @@ -0,0 +1,393 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Chip, MenuItem, TextField, Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess'; +import { + AllowlistRuleFormData, + AllowlistRuleType, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import InstanceAutocomplete, { + InstanceOption, +} from 'lib/components/core/fields/InstanceAutocomplete'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (data: AllowlistRuleFormData) => Promise; +} + +const translations = defineMessages({ + title: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.title', + defaultMessage: 'Add marketplace access rule', + }, + ruleType: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.ruleType', + defaultMessage: 'Rule type', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeUser', + defaultMessage: 'Specific eligible staff member', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance', + defaultMessage: 'All eligible staff in an instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain', + defaultMessage: 'All eligible staff with an email domain', + }, + userEmail: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.userEmail', + defaultMessage: 'Eligible staff email', + }, + instanceLabel: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.instanceId', + defaultMessage: 'Instance', + }, + emailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain', + defaultMessage: 'Email domain (e.g. schools.gov.sg)', + }, + next: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.next', + defaultMessage: 'Next', + }, + back: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.back', + defaultMessage: 'Back', + }, + confirmAdd: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd', + defaultMessage: 'Confirm add', + }, + counts: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.counts', + defaultMessage: 'Grants access to {matched} eligible staff', + }, + countsWithExisting: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsWithExisting', + defaultMessage: + 'Grants access to {matched} eligible staff · {existing} already had access', + }, + noMatches: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.noMatches', + defaultMessage: 'This rule matches nobody eligible right now.', + }, + openToEveryone: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone', + defaultMessage: + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + }, + previewFailure: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure', + defaultMessage: 'Could not preview this rule.', + }, + markerNew: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerNew', + defaultMessage: 'New', + }, + markerExisting: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting', + defaultMessage: 'Already has access', + }, + markerBlocked: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked', + defaultMessage: 'Blocked', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + colName: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colName', + defaultMessage: 'Name', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colStatus', + defaultMessage: 'Status', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, +}); + +const MarketplaceAllowlistRuleForm = ({ + open, + onClose, + onSubmit, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [step, setStep] = useState<1 | 2>(1); + const [ruleType, setRuleType] = useState('email_domain'); + const [value, setValue] = useState(''); + const [instanceId, setInstanceId] = useState(null); + const [instances, setInstances] = useState([]); + const [instancesLoaded, setInstancesLoaded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [preview, setPreview] = useState(null); + // A validation verdict (400) blocks the add; a transport failure does not. + const [rejection, setRejection] = useState(null); + const [previewFailed, setPreviewFailed] = useState(false); + + // The instance list is only needed for the `instance` rule type, so fetch it lazily the first + // time that type is selected — keeps the page's initial load free of an unused request. + useEffect(() => { + if (ruleType !== 'instance' || instancesLoaded) return; + SystemAPI.admin.indexInstances().then((response) => { + setInstances( + response.data.instances.map((instance) => ({ + id: instance.id, + name: instance.name, + })), + ); + setInstancesLoaded(true); + }); + }, [ruleType, instancesLoaded]); + + const buildData = (): AllowlistRuleFormData => { + switch (ruleType) { + case 'user': + return { ruleType, email: value.trim() }; + case 'instance': + return { ruleType, instanceId: instanceId ?? undefined }; + default: + return { ruleType, emailDomain: value.trim() }; + } + }; + + const reset = (): void => { + setStep(1); + setRuleType('email_domain'); + setValue(''); + setInstanceId(null); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + }; + + const handleClose = (): void => { + reset(); + onClose(); + }; + + const goToPreview = async (): Promise => { + setStep(2); + setPreviewing(true); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + + try { + const response = + await SystemAPI.admin.previewMarketplaceAllowlistRule(buildData()); + setPreview(response.data); + } catch (error) { + const response = error instanceof AxiosError ? error.response : undefined; + const message = response?.data?.errors; + if (response?.status === 400 && message) setRejection(message); + else setPreviewFailed(true); + } finally { + setPreviewing(false); + } + }; + + const submit = async (): Promise => { + setSubmitting(true); + await onSubmit(buildData()).finally(() => setSubmitting(false)); + reset(); + }; + + const valueLabel = { + user: t(translations.userEmail), + instance: t(translations.instanceLabel), + email_domain: t(translations.emailDomain), + }[ruleType]; + + const missingValue = + ruleType === 'instance' ? instanceId === null : value.trim() === ''; + + const marker = (user: AllowlistRulePreviewData['users'][number]): string => { + if (user.blocked) return t(translations.markerBlocked); + if (user.alreadyHasAccess) return t(translations.markerExisting); + return t(translations.markerNew); + }; + + const previewColumns: ColumnTemplate< + AllowlistRulePreviewData['users'][number] + >[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( +
+ + {user.name} + + + + {user.email} + +
+ ), + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => + t(translations.managesCourses, { count: user.courseCount }), + }, + { + id: 'status', + title: t(translations.colStatus), + cell: (user) => ( + + ), + }, + ]; + + const renderCounts = (): JSX.Element => { + if (preview === null) return ; + if (preview.openToEveryone) { + return {t(translations.openToEveryone)}; + } + if (preview.matchedCount === 0) { + return {t(translations.noMatches)}; + } + + // "N are new" was noise when everyone is new (the common case); the useful signal is the + // overlap, so name it only when there IS one. + const existing = preview.matchedCount - preview.newCount; + + return ( + + {existing > 0 + ? t(translations.countsWithExisting, { + matched: preview.matchedCount, + existing, + }) + : t(translations.counts, { matched: preview.matchedCount })} + + ); + }; + + const renderStepTwo = (): JSX.Element => { + if (previewing) return ; + if (rejection !== null) return {rejection}; + if (previewFailed) { + return {t(translations.previewFailure)}; + } + + // The prebuilt Table, not a hand-rolled list: a domain or instance rule routinely matches + // hundreds of people, which needs pagination and search, and its real columns keep the three + // headers aligned for free. With nobody matched there is nothing to page or search, so the + // headers and pagination chrome would be furniture around an empty box — the counts line + // already says what happened. + const users = preview?.users ?? []; + + return ( +
+ {renderCounts()} + + {users.length > 0 && ( +
user.id.toString()} + pagination={{ initialPageSize: 10, rowsPerPage: [10, 20, 50, 100] }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + /> + )} + + ); + }; + + return ( + setStep(1)} + onClose={handleClose} + open={open} + primaryDisabled={ + step === 1 + ? missingValue + : submitting || previewing || rejection !== null + } + primaryLabel={ + step === 1 ? t(translations.next) : t(translations.confirmAdd) + } + secondaryLabel={step === 2 ? t(translations.back) : undefined} + title={t(translations.title)} + > + {step === 1 ? ( +
+ { + setRuleType(e.target.value as AllowlistRuleType); + setValue(''); + setInstanceId(null); + }} + select + value={ruleType} + > + {t(translations.typeUser)} + {t(translations.typeInstance)} + + {t(translations.typeEmailDomain)} + + + + {ruleType === 'instance' ? ( + + ) : ( + setValue(e.target.value)} + value={value} + /> + )} +
+ ) : ( +
{renderStepTwo()}
+ )} +
+ ); +}; + +export default MarketplaceAllowlistRuleForm; diff --git a/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx new file mode 100644 index 00000000000..5ce7086851c --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx @@ -0,0 +1,491 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, fireEvent, render, waitFor } from 'test-utils'; + +import SystemAPI from 'api/system'; +import { LOADING_INDICATOR_TEST_ID } from 'lib/components/core/LoadingIndicator'; + +import MarketplaceAllowlistRuleForm from '../MarketplaceAllowlistRuleForm'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const CONFIRM_ADD = 'Confirm add'; +const GRANT_ACCESS_TO_STAFF = 'Grants access to 1 eligible staff'; + +const previewUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 2, + instanceRole: null, + alreadyHasAccess: false, + blocked: false, +}; + +const renderForm = ( + onSubmit = jest.fn().mockResolvedValue(undefined), + onClose = jest.fn(), +): { + page: ReturnType; + onSubmit: jest.Mock; + onClose: jest.Mock; +} => { + const page = render( + , + ); + return { page, onSubmit, onClose }; +}; + +const fillDomainAndAdvance = async ( + page: ReturnType, + domain = NUS_DOMAIN, +): Promise => { + // findBy, not getBy: test-utils' render mounts providers asynchronously, so the dialog's fields + // are not in the DOM on the first tick. + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + domain, + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); +}; + +it('previews the rule once when advancing to step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 12, + newCount: 5, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 12 eligible staff · 7 already had access', + ), + ).toBeVisible(); + // Settle any post-response re-render before pinning the count: a duplicate request fired from an + // effect would be recorded AFTER the counts paint, so asserting at paint time would miss exactly + // the failure this guards against. + await act(async () => { + await Promise.resolve(); + }); + expect(mock.history.post).toHaveLength(1); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); +}); + +it('lists the matched people with links and a new marker', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 2, + newCount: 1, + openToEveryone: false, + users: [ + previewUser, + { + ...previewUser, + id: 2, + name: 'Kumar Raj', + email: 'kumar@nus.edu.sg', + // Distinct from Jane's 2 so each row's count is queryable on its own; also covers the + // singular arm of the `{count, plural, ...}` message. + courseCount: 1, + alreadyHasAccess: true, + }, + ], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); + expect(page.getByText('New')).toBeVisible(); + expect(page.getByText('Already has access')).toBeVisible(); + expect(page.getByText('Manages 2 courses')).toBeVisible(); + expect(page.getByText('Manages 1 course')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('heads the preview list with its three columns', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Name')).toBeVisible(); + expect(page.getByText('Eligible via')).toBeVisible(); + expect(page.getByText('Status')).toBeVisible(); +}); + +it('drops the table entirely when nobody is matched', async () => { + // Column headers and pagination chrome around an empty box say nothing the counts line has not + // already said. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + await page.findByText('This rule matches nobody eligible right now.'); + expect(page.queryByRole('table')).not.toBeInTheDocument(); + expect(page.queryByText('Eligible via')).not.toBeInTheDocument(); + expect( + page.queryByPlaceholderText('Search by name or email'), + ).not.toBeInTheDocument(); +}); + +it('marks a blocked match', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + openToEveryone: false, + users: [{ ...previewUser, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); +}); + +it('prefers the blocked marker over already-has-access', async () => { + // A blocked person may also already hold access; "Blocked" is the marker that matters, because + // the rule will not let them in either way. Without this the two branches could be swapped and + // every other example would still pass. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + openToEveryone: false, + users: [{ ...previewUser, alreadyHasAccess: true, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); + expect(page.queryByText('Already has access')).not.toBeInTheDocument(); +}); + +it('shows a loading state while the preview is in flight', async () => { + let release = (): void => {}; + mock.onPost(PREVIEW_URL).reply( + () => + new Promise((resolve) => { + release = (): void => + resolve([ + 200, + { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }, + ]); + }), + ); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByTestId(LOADING_INDICATOR_TEST_ID)).toBeVisible(); + // Confirming before the verdict lands would create a rule the admin never previewed. + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + + release(); + + expect(await page.findByText(GRANT_ACCESS_TO_STAFF)).toBeVisible(); + expect(page.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('flags a zero-match rule with a warning severity, and keeps it addable', async () => { + // The rule matching nobody reports a problem, so the alert is a warning, not an info note; but a + // zero-match rule is still legitimate (e.g. pre-provisioning a domain before its staff exist), so + // the add stays enabled. Asserting the severity, not just the text, is what pins info→warning. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const message = await page.findByText( + 'This rule matches nobody eligible right now.', + ); + expect(message).toBeVisible(); + expect(message.closest('.MuiAlert-root')).toHaveClass( + 'MuiAlert-standardWarning', + ); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('explains that the rule is inert while the marketplace is open to everyone', async () => { + // matchedCount 0 as well, so this also pins the branch ORDER: the open-to-everyone message must + // win over the "matches nobody" one, which is the more useful thing to say here. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + openToEveryone: true, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + ), + ).toBeVisible(); +}); + +it('blocks a duplicate rule and reports the server message', async () => { + mock.onPost(PREVIEW_URL).reply(400, { + errors: 'Email domain already has a rule.', + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText('Email domain already has a rule.'), + ).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +it('still allows adding when the preview request itself fails', async () => { + // A preview outage is not a verdict on the rule; it must not block creation. + mock.onPost(PREVIEW_URL).reply(500); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('treats a 400 with no message as an outage, not a verdict', async () => { + // Only a 400 that says what is wrong is a rejection. A bare 400 is a broken response, and must + // take the soft path rather than silently blocking creation with no explanation. + mock.onPost(PREVIEW_URL).reply(400, {}); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('submits the rule from step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: CONFIRM_ADD })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + ruleType: 'email_domain', + emailDomain: NUS_DOMAIN, + }), + ); +}); + +it('keeps the entered value when going back to step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: 'Back' })); + + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue( + NUS_DOMAIN, + ); +}); + +it('resets to a clean step 1 when cancelled', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 4, + newCount: 2, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onClose } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText( + 'Grants access to 4 eligible staff · 2 already had access', + ); + + fireEvent.click(page.getByRole('button', { name: 'Cancel' })); + + expect(onClose).toHaveBeenCalled(); + + // The dialog stays mounted (its `open` belongs to the parent), so the reset is observable: back + // at step 1, value cleared, cached preview discarded. Without this the next open would resume + // mid-flow, showing a preview of a rule the admin already abandoned. + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + expect( + page.queryByText( + 'Grants access to 4 eligible staff · 2 already had access', + ), + ).not.toBeInTheDocument(); +}); + +it('previews a user rule from an email address', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + fireEvent.mouseDown(await page.findByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'Specific eligible staff member' }), + ); + + // Surrounding whitespace is a paste artefact, not part of the address. + await userEvent.type( + page.getByLabelText('Eligible staff email'), + ' jane@nus.edu.sg ', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'jane@nus.edu.sg' }, + }); +}); + +it('previews an instance rule, loading the instance list lazily and once', async () => { + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 3, + newCount: 3, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + // The instance list is not fetched until the instance rule type is chosen. + expect(await page.findByLabelText('Rule type')).toBeVisible(); + expect(mock.history.get).toHaveLength(0); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible staff in an instance' }), + ); + + await waitFor(() => + expect( + mock.history.get.filter((r) => r.url === '/admin/instances'), + ).toHaveLength(1), + ); + + // An instance rule has no value until an instance is actually picked. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText('Grants access to 3 eligible staff'); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + expect( + mock.history.get.filter((r) => r.url === '/admin/instances'), + ).toHaveLength(1); +}); + +it('clears the entered value when the rule type changes', async () => { + const { page } = renderForm(); + + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + NUS_DOMAIN, + ); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'Specific eligible staff member' }), + ); + + // A domain is not a plausible email, so it must not carry over into the new field. + expect(page.getByLabelText('Eligible staff email')).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); +}); + +it('does not submit from step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + // Next only previews; the rule is created solely by the step 2 confirmation. + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(onSubmit).not.toHaveBeenCalled(); + expect(page.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); +}); + +it('disables Next until a value is entered', async () => { + const { page } = renderForm(); + + expect(await page.findByRole('button', { name: 'Next' })).toBeDisabled(); + + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx new file mode 100644 index 00000000000..93b4cf6b2c9 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx @@ -0,0 +1,179 @@ +import { ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { StorefrontOutlined, WarningAmber } from '@mui/icons-material'; +import { Tooltip, Typography } from '@mui/material'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + rules: AllowlistRuleData[]; + onDelete: (id: number) => Promise; + disabled?: boolean; + action?: ReactNode; + /** + * Rule id => number of listed users that rule grants access to. Null until the access list below + * has loaded — an unknown count must NOT render as zero, or every rule flashes a warning on load. + * A loaded map with no entry for a rule means it genuinely matches nobody: that is the warning. + */ + matchCounts?: Map | null; +} + +const translations = defineMessages({ + colType: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colType', + defaultMessage: 'Type', + }, + colTarget: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colTarget', + defaultMessage: 'Grants access to', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colActions', + defaultMessage: 'Actions', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain', + defaultMessage: 'Email domain', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceAllowlistTable.deleteConfirm', + defaultMessage: 'Remove this marketplace access rule?', + }, + emptyTitle: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyTitle', + defaultMessage: 'No access rules yet', + }, + emptyHint: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyHint', + defaultMessage: + 'The marketplace stays hidden from everyone except system administrators. Add a rule to grant access.', + }, + zeroMatchWarning: { + id: 'system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning', + defaultMessage: + 'No eligible staff currently match this rule, so it grants access to nobody.', + }, +}); + +const MarketplaceAllowlistTable = ({ + rules, + onDelete, + disabled = false, + action, + matchCounts = null, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const targetOf = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'instance': + return rule.instanceName ?? `#${rule.instanceId}`; + default: + return rule.emailDomain ?? ''; + } + }; + + const renderUserTarget = (rule: AllowlistRuleData): JSX.Element => ( + + + {rule.userName ?? `#${rule.userId}`} + + {rule.userEmail && ` (${rule.userEmail})`} + + ); + + // A loaded map (not null) with no entry for this rule means no listed user is granted by it, i.e. + // it matches nobody. Null is "not loaded yet", which must stay silent. + const matchesNobody = (rule: AllowlistRuleData): boolean => + matchCounts !== null && !matchCounts.has(rule.id); + + const renderTarget = (rule: AllowlistRuleData): JSX.Element => ( + + {matchesNobody(rule) && ( + + + + )} + {rule.ruleType === 'user' ? renderUserTarget(rule) : targetOf(rule)} + + ); + + const columns: ColumnTemplate[] = [ + { + of: 'ruleType', + title: t(translations.colType), + cell: (rule) => typeLabels[rule.ruleType], + }, + { + id: 'target', + title: t(translations.colTarget), + cell: (rule) => renderTarget(rule), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (rule) => ( + => onDelete(rule.id)} + /> + ), + }, + ]; + + const emptyState = ( +
+ + + + {t(translations.emptyTitle)} + + + + {t(translations.emptyHint)} + +
+ ); + + return ( +
+ {action &&
{action}
} + +
+
rule.id.toString()} + renderEmpty={emptyState} + /> + + + ); +}; + +export default MarketplaceAllowlistTable; diff --git a/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx new file mode 100644 index 00000000000..1c48b23adfa --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx @@ -0,0 +1,99 @@ +import { render } from 'test-utils'; + +import MarketplaceAllowlistTable from '../MarketplaceAllowlistTable'; + +const ZERO_MATCH_WARNING = + 'No eligible staff currently match this rule, so it grants access to nobody.'; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: 'typo.edu.sg', +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 7, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const INSTANCE_RULE = { + id: 12, + ruleType: 'instance' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: 3, + instanceName: 'NUS', + emailDomain: null, +}; + +const renderTable = ( + matchCounts: Map | null, + rules: (typeof DOMAIN_RULE | typeof USER_RULE | typeof INSTANCE_RULE)[] = [ + DOMAIN_RULE, + ], +): ReturnType => + render( + , + ); + +it('warns on a rule that a loaded access list grants to nobody', async () => { + // Empty map = the list has loaded and this rule has no entry, so it matches nobody. The tooltip + // text is reachable by accessible name (aria-label) without hovering. + const page = renderTable(new Map()); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the value itself is still shown. + expect(page.getByText('typo.edu.sg')).toBeVisible(); +}); + +it('does not warn on a rule that grants access to at least one person', async () => { + const page = renderTable(new Map([[10, 3]])); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('shows no warning before the access list has loaded', async () => { + // Null = unknown, not zero. A warning here would flash an icon on every rule on first paint — + // the regression this guards against. + const page = renderTable(null); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('warns on a zero-match user rule, not only email-domain rules', async () => { + // The condition is matchCounts.has(id), uniform across rule types. Narrowing it to email_domain + // would leave a user rule that manages nobody just as invisible as it is today. + const page = renderTable(new Map(), [USER_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + expect(page.getByRole('link', { name: 'Jane Tan' })).toBeInTheDocument(); +}); + +it('warns on a zero-match instance rule, completing the three rule types', async () => { + // matchesNobody keys off matchCounts.has(id) and never branches on ruleType, so the instance + // path must warn identically. This also exercises the only otherwise-untested target branch: + // targetOf's instanceName render. + const page = renderTable(new Map(), [INSTANCE_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the instance name is still shown. + expect(page.getByText('NUS')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx new file mode 100644 index 00000000000..071d62650ae --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx @@ -0,0 +1,203 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; + +import MarketplaceAllowlistRuleForm from '../components/forms/MarketplaceAllowlistRuleForm'; +import MarketplaceAccessSection from '../components/MarketplaceAccessSection'; +import MarketplaceAllowlistModeBanner from '../components/MarketplaceAllowlistModeBanner'; +import MarketplaceAllowlistTable from '../components/tables/MarketplaceAllowlistTable'; + +type Props = WrappedComponentProps; + +const translations = defineMessages({ + addRule: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.addRule', + defaultMessage: 'Add access rule', + }, + eligibility: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.eligibility', + defaultMessage: + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace access rules.', + }, + createSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createSuccess', + defaultMessage: 'Access rule added.', + }, + createFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createFailure', + defaultMessage: 'Failed to add access rule.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess', + defaultMessage: 'Access rule removed.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteFailure', + defaultMessage: 'Failed to remove access rule.', + }, + openSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openSuccess', + defaultMessage: 'Marketplace opened to all course managers.', + }, + openFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openFailure', + defaultMessage: 'Failed to open the marketplace to everyone.', + }, + restrictSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess', + defaultMessage: 'Marketplace restricted to the scoped rules.', + }, + restrictFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictFailure', + defaultMessage: 'Failed to restrict the marketplace.', + }, +}); + +const MarketplaceAllowlistIndex: FC = ({ intl }) => { + const [isLoading, setIsLoading] = useState(true); + const [isFormOpen, setIsFormOpen] = useState(false); + const [rules, setRules] = useState([]); + const [everyoneRuleId, setEveryoneRuleId] = useState(null); + // Bumped on every rule mutation. Adding a domain rule changes who is in the access list in ways + // the client cannot compute locally, so the list must refetch rather than patch itself. + const [ruleVersion, setRuleVersion] = useState(0); + // Published by the access section after each fetch; passed to the rules table so it can flag rules + // that grant access to nobody. Null until the first fetch resolves (unknown ≠ zero). + const [matchCounts, setMatchCounts] = useState | null>( + null, + ); + + useEffect(() => { + SystemAPI.admin + .indexMarketplaceAllowlistRules() + .then((response) => { + setRules(response.data.rules); + setEveryoneRuleId(response.data.everyoneRuleId ?? null); + }) + .catch(() => toast.error(intl.formatMessage(translations.fetchFailure))) + .finally(() => setIsLoading(false)); + }, []); + + const openToEveryone = everyoneRuleId !== null; + const invalidateAccessList = (): void => { + // Blank the counts until the refetch this triggers resolves: they are derived from the access + // list, so between a mutation and the fresh fetch they are stale. After a Restrict, the + // everyone-mode counts are an empty map that would mark every scoped rule as matching nobody. + // Null means "unknown, don't warn", same as before the first load. + setMatchCounts(null); + setRuleVersion((version) => version + 1); + }; + + const handleCreate = async (data: AllowlistRuleFormData): Promise => { + try { + const response = + await SystemAPI.admin.createMarketplaceAllowlistRule(data); + setRules((current) => [...current, response.data]); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.createSuccess)); + setIsFormOpen(false); + } catch (error) { + // Surface the server's reason (e.g. the duplicate-rule message) — the generic fallback + // would discard exactly the message that was written for this case. + const message = + error instanceof AxiosError ? error.response?.data?.errors : undefined; + toast.error(message ?? intl.formatMessage(translations.createFailure)); + } + }; + + const handleDelete = async (id: number): Promise => { + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(id); + setRules((current) => current.filter((rule) => rule.id !== id)); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.deleteSuccess)); + } catch { + toast.error(intl.formatMessage(translations.deleteFailure)); + } + }; + + const handleOpenToEveryone = async (): Promise => { + try { + const response = await SystemAPI.admin.openMarketplaceToEveryone(); + setEveryoneRuleId(response.data.id); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.openSuccess)); + } catch { + toast.error(intl.formatMessage(translations.openFailure)); + } + }; + + const handleRestrict = async (): Promise => { + if (everyoneRuleId === null) return; + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(everyoneRuleId); + setEveryoneRuleId(null); + invalidateAccessList(); + toast.success(intl.formatMessage(translations.restrictSuccess)); + } catch { + toast.error(intl.formatMessage(translations.restrictFailure)); + } + }; + + if (isLoading) return ; + + return ( + + + {intl.formatMessage(translations.eligibility)} + + + + setIsFormOpen(true)} + > + {intl.formatMessage(translations.addRule)} + + } + disabled={openToEveryone} + matchCounts={openToEveryone ? null : matchCounts} + onDelete={handleDelete} + rules={rules} + /> + + setIsFormOpen(false)} + onSubmit={handleCreate} + open={isFormOpen} + /> + + + + ); +}; + +export default injectIntl(MarketplaceAllowlistIndex); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx new file mode 100644 index 00000000000..0a0b0b44327 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -0,0 +1,642 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import SystemAPI from 'api/system'; + +import MarketplaceAllowlistIndex from '../MarketplaceAllowlistIndex'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => { + mock.reset(); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [], + summary: { totalWithAccess: 0, openToEveryone: false }, + }); +}); + +const INDEX_URL = '/admin/marketplace_allowlist_rules'; +const EMAIL_DOMAIN = 'schools.gov.sg'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const OPEN_TO_EVERYONE = 'Open to everyone'; +const allowlistGetCount = (): number => + mock.history.get.filter((request) => request.url === INDEX_URL).length; +const RULES = [ + { + id: 1, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: EMAIL_DOMAIN, + }, +]; +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const accessGetCount = (): number => + mock.history.get.filter( + (request) => request.url === '/admin/marketplace_access', + ).length; +// Step 2's preview is a POST too, so `mock.history.post[0]` is the preview, not the create. +const createPosts = (): typeof mock.history.post => + mock.history.post.filter((request) => request.url === INDEX_URL); + +/** + * Click step 2's "Confirm add". The button is disabled while the preview request is in flight, so + * a click fired the moment it appears is swallowed — wait for it to enable first. + */ +const confirmAdd = async (page: ReturnType): Promise => { + const button = await page.findByRole('button', { name: 'Confirm add' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); +}; + +it('renders the allow-list rules from the API', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + const page = render(, { at: [INDEX_URL] }); + + // Await the fetch firing before asserting the rendered row, so mount + request and the + // subsequent re-render each get their own waitFor budget (a single window is flaky under load). + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); +}); + +it('creates an email-domain rule from the add dialog', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + // Rule type defaults to Email domain; fill the value field. (Search fields need userEvent — + // see client/CLAUDE-testing.md; a plain TextField accepts userEvent.type too.) + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); + await waitFor(() => expect(page.getByText(NUS_DOMAIN)).toBeVisible()); +}); + +it('deletes a rule after confirmation', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + await waitFor(() => + expect(page.queryByText(EMAIL_DOMAIN)).not.toBeInTheDocument(), + ); +}); + +it('opens the marketplace to everyone from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Scoped state: the banner switch is off; flipping it on prompts to open. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + + // Confirm inside the dialog (its primary button shares the label, so scope to the dialog). + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'everyone' }, + }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); +}); + +it('restricts the marketplace to scoped rules from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible staff: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); + + // Open state: the banner switch is on; flipping it off prompts to restrict. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/42`); + await waitFor(() => + expect( + page.getByText('Access is limited to the rules below.'), + ).toBeVisible(), + ); +}); + +it('disables adding and removing rules while the marketplace is open to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Open-to-everyone means the scoped rules are preserved but inactive: no add, no delete. + expect(page.getByRole('button', { name: 'Add access rule' })).toBeDisabled(); + expect(page.getByTestId('DeleteIconButton')).toBeDisabled(); +}); + +it('disables Next until a required value is entered', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + + // Default rule type is email_domain → value required → Add disabled while empty. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + // Entering the required value enables it. + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('creates a user rule from an email', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 4, + ruleType: 'user', + userId: 7, + userName: 'Teacher', + userEmail: 'teacher@school.edu', + instanceId: null, + instanceName: null, + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'Specific eligible staff member' }), + ); + + await userEvent.type( + page.getByLabelText('Eligible staff email'), + 'teacher@school.edu', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' }, + }); + + const link = await page.findByRole('link', { name: 'Teacher' }); + expect(link).toHaveAttribute('href', '/users/7'); + expect(page.getByText('(teacher@school.edu)')).toBeVisible(); +}); + +it('clears the entered value when the rule type changes', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + + // Enter an email domain, then switch the rule type to "Specific eligible staff member". + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'Specific eligible staff member' }), + ); + + // The new value field must start empty, not carry over NUS_DOMAIN. + expect(page.getByLabelText('Eligible staff email')).toHaveValue(''); +}); + +it('shows who is eligible for the marketplace', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText( + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + ), + ).toBeVisible(); +}); + +it('renders a user rule as a link to the user with their email', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 5, + ruleType: 'user', + userId: 42, + userName: 'Administrator', + userEmail: 'admin@org.sg', + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'Administrator' }); + expect(link).toHaveAttribute('href', '/users/42'); + expect(page.getByText('(admin@org.sg)')).toBeVisible(); +}); + +it('creates an instance rule by picking an instance from the dropdown', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 6, + ruleType: 'instance', + userId: null, + userName: null, + userEmail: null, + instanceId: 2, + instanceName: 'Alpha', + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible staff in an instance' }), + ); + + // Selecting the instance rule type lazily fetches the instance list. + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + await waitFor(() => expect(page.getByText('Alpha')).toBeVisible()); +}); + +it('renders a user rule without an email suffix when none is present', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 8, + ruleType: 'user', + userId: 12, + userName: 'No Email User', + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'No Email User' }); + expect(link).toHaveAttribute('href', '/users/12'); + // Guard: no ` (…)` suffix — the cell's text is exactly the user name. + // (A `/\(.*\)/` regex would false-match the eligibility subtitle's "(of any course)"; + // the exact textContent check is robust and still fails if the guard is dropped, since a + // null email would render "No Email User (null)".) + expect(link.parentElement?.textContent).toBe('No Email User'); +}); + +it('disables Next for an instance rule until an instance is picked', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible staff in an instance' }), + ); + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('keeps the Open to everyone toggle label on a single line', async () => { + // The open-state banner body is long enough to wrap, which used to drag the toggle label with it. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + + const label = await page.findByText(OPEN_TO_EVERYONE); + expect(label).toHaveClass('whitespace-nowrap'); +}); + +it('refreshes the access list after a rule is added', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after a rule is deleted', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is opened to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is restricted again', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('surfaces the server message when a rule is rejected as a duplicate', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + mock.onPost(INDEX_URL).reply(400, { + errors: 'Email domain already has a rule.', + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + // The specific message, not the generic "Failed to add access rule." + expect( + await page.findByText('Email domain already has a rule.'), + ).toBeVisible(); +}); + +const ZERO_MATCH_WARNING = + 'No eligible staff currently match this rule, so it grants access to nobody.'; + +it('flags a rule that the loaded access list grants to nobody', async () => { + // beforeEach returns no access-list users, so the single email-domain rule matches nobody. + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); +}); + +it('does not flag a rule that the access list grants to someone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [ + { + id: 1, + name: 'Jane Tan', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render(, { at: [INDEX_URL] }); + // Wait for the access list to render (counts are published only after it resolves). + await page.findByText('jane@schools.gov.sg'); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('suppresses zero-match warnings while the marketplace is open to everyone', async () => { + // Everyone-mode empties scoped_rules, so every rule would report zero — but the mode banner + // already says the rules are moot, so the page passes null and shows no icons at all. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('does not flash zero-match warnings while a refetch after restrict is in flight', async () => { + // Restrict flips openToEveryone off and triggers a refetch. Until it resolves, the previously + // published counts are stale: everyone-mode publishes an empty map, which would mark every scoped + // rule as matching nobody. invalidateAccessList must blank matchCounts to null so no false warning + // shows in that window. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + let releaseSecond = (): void => {}; + let accessCalls = 0; + mock.onGet('/admin/marketplace_access').reply(() => { + accessCalls += 1; + if (accessCalls === 1) { + // Everyone-mode: users carry no per-rule reasons, so the published map is empty. + return [ + 200, + { users: [], summary: { totalWithAccess: 0, openToEveryone: true } }, + ]; + } + // Second fetch (after restrict) stays pending until released. + return new Promise((resolve) => { + releaseSecond = (): void => + resolve([ + 200, + { + users: [ + { + id: 1, + name: 'Jane', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { + totalWithAccess: 1, + totalBlocked: 0, + openToEveryone: false, + }, + }, + ]); + }); + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + await page.findByText(EMAIL_DOMAIN); + + // Restrict: toggle off, then confirm in the dialog. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + // Refetch is now in flight (second GET pending). No stale zero-match warning may show. + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); + + // Let the refetch resolve; Jane matches rule 1, so still no warning. + releaseSecond(); + await page.findByText('jane@schools.gov.sg'); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); diff --git a/client/app/lib/components/core/fields/InstanceAutocomplete.tsx b/client/app/lib/components/core/fields/InstanceAutocomplete.tsx new file mode 100644 index 00000000000..97b1c353dee --- /dev/null +++ b/client/app/lib/components/core/fields/InstanceAutocomplete.tsx @@ -0,0 +1,72 @@ +import { ReactNode } from 'react'; +import { Autocomplete, Box } from '@mui/material'; + +import TextField from 'lib/components/core/fields/TextField'; + +export interface InstanceOption { + id: number; + name: string; +} + +interface Props { + options: InstanceOption[]; + value: number | null; + onChange: (instanceId: number | null) => void; + label: ReactNode; + disabled?: boolean; + required?: boolean; + error?: boolean; + helperText?: ReactNode; + onBlur?: () => void; +} + +/** + * Presentational instance picker shared by the course-duplication destination form and the + * system-admin marketplace allow-list form. Framework-agnostic: the caller supplies the option + * list and a plain value/onChange pair, so it works with react-hook-form or bare useState alike. + */ +const InstanceAutocomplete = ({ + options, + value, + onChange, + label, + disabled = false, + required = false, + error = false, + helperText, + onBlur, +}: Props): JSX.Element => { + const selected = options.find((option) => option.id === value) ?? null; + + return ( + option.name} + isOptionEqualToValue={(option, chosen): boolean => + option.id === chosen.id + } + onBlur={onBlur} + onChange={(_, option): void => onChange(option?.id ?? null)} + options={options} + renderInput={(inputProps): JSX.Element => ( + + )} + renderOption={(optionProps, option): JSX.Element => ( + + {option.name} + + )} + value={selected} + /> + ); +}; + +export default InstanceAutocomplete; diff --git a/client/app/lib/components/core/fields/__test__/InstanceAutocomplete.test.tsx b/client/app/lib/components/core/fields/__test__/InstanceAutocomplete.test.tsx new file mode 100644 index 00000000000..f26af7c95dc --- /dev/null +++ b/client/app/lib/components/core/fields/__test__/InstanceAutocomplete.test.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { fireEvent, render } from 'test-utils'; + +import InstanceAutocomplete, { InstanceOption } from '../InstanceAutocomplete'; + +const OPTIONS: InstanceOption[] = [ + { id: 1, name: 'Default' }, + { id: 2, name: 'Alpha' }, +]; + +const Harness = ({ + onChange, +}: { + onChange: (id: number | null) => void; +}): JSX.Element => { + const [value, setValue] = useState(null); + return ( + { + setValue(id); + onChange(id); + }} + options={OPTIONS} + value={value} + /> + ); +}; + +it("shows the selected option's name and reports the chosen id", async () => { + const onChange = jest.fn(); + const page = render(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(onChange).toHaveBeenCalledWith(2); + expect(combobox).toHaveValue('Alpha'); +}); + +it('reports null when the selection is cleared', async () => { + const onChange = jest.fn(); + const page = render(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Default' })); + expect(onChange).toHaveBeenLastCalledWith(1); + + fireEvent.click(page.getByTitle('Clear')); + expect(onChange).toHaveBeenLastCalledWith(null); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 79edf10de6f..2e1bba29aac 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -67,6 +67,17 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_allowlist_rules', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceAllowlistIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceAllowlistIndex' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/marketplaceAccess.ts b/client/app/types/system/marketplaceAccess.ts new file mode 100644 index 00000000000..10cd12f0875 --- /dev/null +++ b/client/app/types/system/marketplaceAccess.ts @@ -0,0 +1,51 @@ +import { AllowlistRuleType } from 'types/system/marketplaceAllowlist'; + +/** + * One rule granting a user access. A user may be granted by several rules at once — the audit list + * shows all of them, because the admin uses that column to decide which rules are safe to delete. + */ +export interface AllowedByRule { + id: number; + ruleType: AllowlistRuleType; + labelValue: string | null; +} + +export interface MarketplaceAccessUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + allowedByRules: AllowedByRule[]; + /** System admins bypass every gate, so they are listed and labelled regardless of the rules. */ + systemAdmin: boolean; + blocked: boolean; + blockId: number | null; +} + +export interface MarketplaceAccessData { + users: MarketplaceAccessUser[]; + summary: { + totalWithAccess: number; + totalBlocked: number; + openToEveryone: boolean; + }; +} + +export interface MarketplaceRulePreviewUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + alreadyHasAccess: boolean; + blocked: boolean; +} + +export interface AllowlistRulePreviewData { + matchedCount: number; + /** Matched users who are neither already cleared by another rule nor blocked. */ + newCount: number; + openToEveryone: boolean; + users: MarketplaceRulePreviewUser[]; +} diff --git a/client/app/types/system/marketplaceAllowlist.ts b/client/app/types/system/marketplaceAllowlist.ts new file mode 100644 index 00000000000..bae03b1f717 --- /dev/null +++ b/client/app/types/system/marketplaceAllowlist.ts @@ -0,0 +1,19 @@ +export type AllowlistRuleType = 'user' | 'instance' | 'email_domain'; + +export interface AllowlistRuleData { + id: number; + ruleType: AllowlistRuleType; + userId: number | null; + userName: string | null; + userEmail: string | null; + instanceId: number | null; + instanceName: string | null; + emailDomain: string | null; +} + +export interface AllowlistRuleFormData { + ruleType: AllowlistRuleType; + email?: string; + instanceId?: number; + emailDomain?: string; +} diff --git a/client/locales/en.json b/client/locales/en.json index 0073cd7ca72..92d72b18aed 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8864,6 +8864,12 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "Renamed {field} from {prevValue} to {newValue}" }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstanceRole": { + "defaultMessage": "Instance role" + }, + "system.admin.admin.MarketplaceAllowlistTable.targetInstanceRole": { + "defaultMessage": "Instructors & administrators (any instance)" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "Delete User" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index a035bfb56b8..000af6c3cac 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8831,6 +8831,12 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "{field}이(가) {prevValue}에서 {newValue}로 변경되었습니다." }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstanceRole": { + "defaultMessage": "인스턴스 역할" + }, + "system.admin.admin.MarketplaceAllowlistTable.targetInstanceRole": { + "defaultMessage": "강사 및 관리자(모든 인스턴스)" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "사용자 삭제" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 62d7829973e..f7ab856a113 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8825,6 +8825,12 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "已将 {field} 从 {prevValue} 重命名为 {newValue}" }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstanceRole": { + "defaultMessage": "实例角色" + }, + "system.admin.admin.MarketplaceAllowlistTable.targetInstanceRole": { + "defaultMessage": "教师和管理员(任何实例)" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "删除用户" }, diff --git a/config/locales/en/activerecord/attributes.yml b/config/locales/en/activerecord/attributes.yml index f5586333528..8869d328563 100644 --- a/config/locales/en/activerecord/attributes.yml +++ b/config/locales/en/activerecord/attributes.yml @@ -15,6 +15,13 @@ en: weight: 'Order' course/assessment/category/title: default: 'Assessments' + # Admins read these attribute names verbatim: the allow-list controller renders + # `errors.full_messages.to_sentence` straight into a toast, so without these entries a + # validation failure surfaces as the raw i18n key. + course/assessment/marketplace/allowlist_rule: + user_id: 'User' + instance_id: 'Instance' + email_domain: 'Email domain' course/assessment/question: weight: 'Order' course/assessment/submission: diff --git a/config/routes.rb b/config/routes.rb index 026a51fdecf..e40eeb54d47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,13 @@ get '/' => 'admin#index' get 'deployment_info' => 'admin#deployment_info' resources :announcements, only: [:index, :create, :update, :destroy] + resources :marketplace_allowlist_rules, only: [:index, :create, :destroy] do + # Dry run: reports who a prospective rule would let in. POST because it carries a body, + # not because it mutates - the action never saves. + post :preview, on: :collection + end + get 'marketplace_access' => 'marketplace_access#index' + resources :marketplace_access_blocks, only: [:create, :destroy] resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb b/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..dccb44bd76c --- /dev/null +++ b/db/migrate/20260707000003_create_course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class CreateCourseAssessmentMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_allowlist_rules do |t| + t.integer :rule_type, null: false + t.references :user, foreign_key: { to_table: :users }, null: true, index: true + t.references :instance, foreign_key: true, null: true, index: true + t.string :email_domain, null: true + t.timestamps + end + add_index :course_assessment_marketplace_allowlist_rules, :email_domain + end +end diff --git a/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb b/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..8d65d902ffb --- /dev/null +++ b/db/migrate/20260716000001_add_everyone_uniqueness_to_marketplace_allowlist_rules.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class AddEveryoneUniquenessToMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + # `everyone` is rule_type == 3 (enum below). A partial unique index enforces at most one such row, + # the DB-level backstop for the model's `uniqueness` validation (repo rule: pair the two). + add_index :course_assessment_marketplace_allowlist_rules, :rule_type, + unique: true, where: 'rule_type = 3', + name: 'index_marketplace_allowlist_rules_one_everyone' + end +end diff --git a/db/migrate/20260720061212_create_course_assessment_marketplace_access_blocks.rb b/db/migrate/20260720061212_create_course_assessment_marketplace_access_blocks.rb new file mode 100644 index 00000000000..3bcffd9f9d3 --- /dev/null +++ b/db/migrate/20260720061212_create_course_assessment_marketplace_access_blocks.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class CreateCourseAssessmentMarketplaceAccessBlocks < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_access_blocks do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.references :creator, null: false, foreign_key: { to_table: :users } + t.timestamps + end + end +end diff --git a/db/migrate/20260720154800_add_per_type_uniqueness_to_marketplace_allowlist_rules.rb b/db/migrate/20260720154800_add_per_type_uniqueness_to_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..90919f8c18c --- /dev/null +++ b/db/migrate/20260720154800_add_per_type_uniqueness_to_marketplace_allowlist_rules.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true +class AddPerTypeUniquenessToMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + TABLE = :course_assessment_marketplace_allowlist_rules + + def up + normalize_email_domains + delete_duplicates + + add_index TABLE, :user_id, unique: true, where: 'rule_type = 0', + name: 'index_marketplace_allowlist_rules_one_per_user' + add_index TABLE, :instance_id, unique: true, where: 'rule_type = 1', + name: 'index_marketplace_allowlist_rules_one_per_instance' + add_index TABLE, :email_domain, unique: true, where: 'rule_type = 2', + name: 'index_marketplace_allowlist_rules_one_per_email_domain' + end + + def down + remove_index TABLE, name: 'index_marketplace_allowlist_rules_one_per_user' + remove_index TABLE, name: 'index_marketplace_allowlist_rules_one_per_instance' + remove_index TABLE, name: 'index_marketplace_allowlist_rules_one_per_email_domain' + end + + private + + # Domain matching has always been case-insensitive, so `MOE.GOV.SG` and `moe.gov.sg` are the same + # rule and must collide under the index added below. + def normalize_email_domains + execute(<<~SQL.squish) + UPDATE #{TABLE} SET email_domain = LOWER(TRIM(email_domain)) + WHERE rule_type = 2 AND email_domain IS NOT NULL + SQL + end + + # Exactly-redundant rows: a duplicate granted nothing its survivor does not, so keeping the lowest + # id loses no configuration. Without this, the unique indexes cannot be created on any database + # that already accumulated duplicates — including developer machines. + def delete_duplicates + execute(<<~SQL.squish) + DELETE FROM #{TABLE} a USING #{TABLE} b + WHERE a.id > b.id AND a.rule_type = b.rule_type + AND ((a.rule_type = 0 AND a.user_id = b.user_id) + OR (a.rule_type = 1 AND a.instance_id = b.instance_id) + OR (a.rule_type = 2 AND a.email_domain = b.email_domain)) + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index 7eeadc353c1..0d63cf08be9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do +ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,15 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_access_blocks", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "idx_on_creator_id_becaf2e041" + t.index ["user_id"], name: "index_course_assessment_marketplace_access_blocks_on_user_id", unique: true + end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| t.bigint "listing_id", null: false t.bigint "destination_course_id", null: false @@ -286,6 +295,22 @@ t.index ["updater_id"], name: "fk__cama_updater_id" end + create_table "course_assessment_marketplace_allowlist_rules", force: :cascade do |t| + t.integer "rule_type", null: false + t.bigint "user_id" + t.bigint "instance_id" + t.string "email_domain" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_domain"], name: "idx_on_email_domain_6577b88d4e" + t.index ["email_domain"], name: "index_marketplace_allowlist_rules_one_per_email_domain", unique: true, where: "(rule_type = 2)" + t.index ["instance_id"], name: "idx_on_instance_id_77af5cff27" + t.index ["instance_id"], name: "index_marketplace_allowlist_rules_one_per_instance", unique: true, where: "(rule_type = 1)" + t.index ["rule_type"], name: "index_marketplace_allowlist_rules_one_everyone", unique: true, where: "(rule_type = 3)" + t.index ["user_id"], name: "index_course_assessment_marketplace_allowlist_rules_on_user_id" + t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)" + end + create_table "course_assessment_marketplace_listings", force: :cascade do |t| t.bigint "assessment_id", null: false t.boolean "published", default: false, null: false @@ -1978,11 +2003,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_access_blocks", "users" + add_foreign_key "course_assessment_marketplace_access_blocks", "users", column: "creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 9c02a8ac2db..bdf1398de0e 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -12,6 +12,8 @@ before { controller_sign_in(controller, manager.user) } describe 'GET #index' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } @@ -69,12 +71,117 @@ end end end + describe 'GET #index visibility gate' do + subject { get :index, params: { course_id: course.id, format: :json } } + + # The suite runs with `use_transactional_fixtures = false` (see spec/rails_helper.rb), so rows + # persist across examples/runs. `:everyone` is a DB-enforced singleton (one row allowed), so any + # leftover row here would either block a later `create(:everyone)` with a uniqueness error or + # spuriously widen access in a sibling example. Mirrors the cleanup in + # spec/models/course/assessment/marketplace/allowlist_rule_spec.rb. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + context 'when the manager is not on the allow-list' do + before { controller_sign_in(controller, manager.user) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-list rule matches the manager' do + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + controller_sign_in(controller, manager.user) + end + + it 'permits access' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user is a system administrator' do + let(:admin) { create(:administrator) } + before do + create(:course_manager, course: course, user: admin) + controller_sign_in(controller, admin) + end + + it 'permits access without an allow-list rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists" do + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, manager.user) + end + + it 'permits a manager who has no matching scoped rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists but the user is a student" do + let(:student) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, student) + end + + it 'still denies a non-manager (the manager gate holds)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the user is an observer here but manages another course' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: roamer) + controller_sign_in(controller, roamer) + end + + it 'permits browsing (access is per-person, not per-current-course role)' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user manages another course but is not on the allow-list' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + controller_sign_in(controller, roamer) + end + + it 'denies access (allow-list is still required even for a manager elsewhere)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-listed user manages no course' do + let(:pupil) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: pupil) + controller_sign_in(controller, pupil) + end + + it 'denies access (must manage at least one course)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + end + describe 'POST #duplicate' do # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. # `run_rescue` re-enables handle_access_denied so AccessDenied renders 403 rather than # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + with_active_job_queue_adapter(:test) do let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } let!(:tab) { course.assessment_categories.first.tabs.first } @@ -116,6 +223,8 @@ # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) @@ -175,6 +284,7 @@ ActsAsTenant.with_tenant(home_instance) do course = create(:course) manager = create(:course_manager, course: course) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) controller_sign_in(controller, manager.user) # Point the request at the home instance's host so `deduce_tenant` resolves it (this # describe is outside `with_tenant`, which would otherwise set the host header for us). diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index b6d35b3839c..de6d5949df3 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -35,7 +35,10 @@ let(:destination_course) { create(:course) } let(:manager) { create(:course_manager, course: destination_course).user } - before { controller_sign_in(controller, manager) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager) + controller_sign_in(controller, manager) + end it 'serializes the question across instances' do get :show, as: :json, params: { diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb index 6fa1c1f6aab..78cd6db53f5 100644 --- a/spec/controllers/course/assessment_marketplace_component_spec.rb +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -15,7 +15,10 @@ context 'when the user can access the marketplace (course manager)' do let(:user) { create(:course_manager, course: course).user } - before { controller_sign_in(controller, user) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + controller_sign_in(controller, user) + end it 'exposes an admin sidebar item pointing at the marketplace' do item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } diff --git a/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb new file mode 100644 index 00000000000..b320410020f --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessBlocksController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AccessBlock.delete_all + controller_sign_in(controller, admin) + end + + describe 'POST #create' do + it 'blocks the user and returns the block id' do + target = create(:user) + expect do + post :create, format: :json, params: { user_id: target.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(1) + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userId']).to eq(target.id) + expect(response.parsed_body['id']).to be_present + end + + it 'rejects a duplicate block for the same user' do + target = create(:user) + create(:course_assessment_marketplace_access_block, user: target) + expect do + post :create, format: :json, params: { user_id: target.id } + end.not_to(change { Course::Assessment::Marketplace::AccessBlock.count }) + expect(response).to have_http_status(:bad_request) + end + end + + describe 'DELETE #destroy' do + it 'removes the block' do + block = create(:course_assessment_marketplace_access_block) + expect do + delete :destroy, format: :json, params: { id: block.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + post :create, format: :json, params: { user_id: create(:user).id } + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_access_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_controller_spec.rb new file mode 100644 index 00000000000..8d753cfaf6c --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern leaves rows behind that collide on + # the next run. + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + controller_sign_in(controller, admin) + end + + describe 'GET #index' do + render_views + + it 'lists eligible users with annotations and a summary' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + + get :index, format: :json + expect(response).to have_http_status(:ok) + + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row).to be_present + expect(row['allowedByRules'].map { |r| r['ruleType'] }).to eq(['user']) + expect(row['courseCount']).to eq(1) + expect(row['blocked']).to be(false) + expect(row['systemAdmin']).to be(false) + # System admins are always listed and always count as having access; the test DB accumulates + # them across runs (nothing rolls back), so the total is relative to however many exist. + expect(response.parsed_body['summary']['totalWithAccess']).to eq(1 + User.administrator.count) + expect(response.parsed_body['summary']['openToEveryone']).to be(false) + end + + it 'flags a blocked user with a blockId' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + block = create(:course_assessment_marketplace_access_block, user: manager.user) + + get :index, format: :json + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row['blocked']).to be(true) + expect(row['blockId']).to eq(block.id) + expect(response.parsed_body['summary']['totalWithAccess']).to eq(User.administrator.count) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + describe 'GET #index serialization' do + render_views + + it 'serializes every matching rule with its label, and the blocked total' do + user = create(:user, email: 'listed@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + create(:course_assessment_marketplace_access_block, user: user) + + get :index, format: :json + + expect(response).to have_http_status(:ok) + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row).not_to be_nil + expect(row['allowedByRules']).to contain_exactly( + { 'id' => user_rule.id, 'ruleType' => 'user', 'labelValue' => user.name }, + { 'id' => domain_rule.id, 'ruleType' => 'email_domain', + 'labelValue' => 'schools.gov.sg' } + ) + expect(response.parsed_body['summary']['totalBlocked']).to eq(1) + end + + it 'serializes an instance rule with the instance name as its label' do + other_instance = create(:instance) + user = create(:user) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row['allowedByRules']).to eq( + ['id' => rule.id, 'ruleType' => 'instance', 'labelValue' => other_instance.name] + ) + end + + it 'serializes an empty rule list for a user listed only because they are blocked' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_access_block, user: cu.user) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == cu.user.id } + expect(row['allowedByRules']).to eq([]) + expect(row['blocked']).to be(true) + end + + it 'serializes a system admin who manages nothing and matches no rule' do + admin = create(:administrator) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == admin.id } + expect(row).not_to be_nil + expect(row['systemAdmin']).to be(true) + expect(row['allowedByRules']).to eq([]) + expect(row['courseCount']).to eq(0) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb new file mode 100644 index 00000000000..e3da24dd4e6 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAllowlistRulesController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + # Email-domain rules are unique per domain and specs commit, so the row this example creates + # would collide with itself on the next run. Clear it first. + before do + Course::Assessment::Marketplace::AllowlistRule. + rule_type_email_domain.where(email_domain: 'schools.gov.sg').delete_all + end + + subject do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'email_domain', email_domain: 'schools.gov.sg' } + } + end + + it 'creates an email-domain rule' do + expect { subject }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(1) + expect(response).to have_http_status(:ok) + end + end + + describe 'POST #create for a user rule by email' do + render_views + # No transactional fixtures / DatabaseCleaner here (see GET #index note), so a user with this + # hardcoded email can persist from an earlier run and collide on email uniqueness. Clear it. + before { User::Email.where(email: 'teacher@school.edu').delete_all } + + it 'resolves a confirmed email to the owning user and creates a user rule' do + target = create(:user, email: 'teacher@school.edu') + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_user.count }.by(1) + expect(response).to have_http_status(:ok) + expect(Course::Assessment::Marketplace::AllowlistRule.rule_type_user.last.user).to eq(target) + end + + it 'serializes the resolved user\'s email as userEmail in the rendered rule' do + create(:user, email: 'teacher@school.edu') + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userEmail']).to eq('teacher@school.edu') + end + + it 'rejects an email that matches no user' do + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'nobody@nowhere.test' } + } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + end + + describe 'GET #index' do + render_views + # This suite runs with `use_transactional_fixtures = false` and no DatabaseCleaner, so rows + # created by earlier local runs of this factory persist in the dev/test DB; scope to a clean + # slate here so the size assertion below is deterministic. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'lists the rules' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe 'GET #index everyone-mode reporting' do + render_views + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'reports everyoneRuleId null and lists only scoped rules when no everyone rule exists' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to be_nil + expect(response.parsed_body['rules'].size).to eq(1) + end + + it 'reports everyoneRuleId and excludes the everyone rule from the list' do + everyone = create(:course_assessment_marketplace_allowlist_rule, :everyone) + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to eq(everyone.id) + expect(response.parsed_body['rules'].map { |r| r['ruleType'] }).not_to include('everyone') + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe "POST #create with rule_type 'everyone'" do + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'opens the marketplace to everyone' do + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_everyone.count }.by(1) + expect(response).to have_http_status(:ok) + end + + it 'rejects a second everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + end + + it 'surfaces the uniqueness error when rejected' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + expect(response.parsed_body['errors']).to include('already been taken') + end + end + + describe 'DELETE #destroy' do + let!(:rule) { create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) } + + it 'removes the rule' do + expect { delete :destroy, format: :json, params: { id: rule.id } }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + + describe 'POST #preview' do + render_views + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor — see the note in Task 1's spec: an anchored, case-sensitive + # pattern misses rows the `lower(email)` unique index will still collide on. + User::Email.where('LOWER(email) LIKE ?', '%preview.test').delete_all + end + + def preview(params) + post :preview, format: :json, params: { allowlist_rule: params } + end + + it 'counts eligible staff a domain rule would match, and how many are new' do + newcomer = create(:user, email: 'newcomer@preview.test') + create(:course_manager, course: create(:course), user: newcomer) + existing = create(:user, email: 'existing@preview.test') + create(:course_manager, course: create(:course), user: existing) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: existing) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['matchedCount']).to eq(2) + expect(body['newCount']).to eq(1) + expect(body['openToEveryone']).to be(false) + expect(body['users'].map { |u| u['id'] }).to contain_exactly(newcomer.id, existing.id) + expect(body['users'].find { |u| u['id'] == existing.id }['alreadyHasAccess']).to be(true) + expect(body['users'].find { |u| u['id'] == newcomer.id }['alreadyHasAccess']).to be(false) + end + + it 'persists nothing' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'dryrun@preview.test')) + + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + end + + it 'excludes users who are not baseline-eligible' do + create(:course_student, course: create(:course), + user: create(:user, email: 'student@preview.test')) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'counts a blocked match but never as new' do + blocked = create(:user, email: 'blocked@preview.test') + create(:course_manager, course: create(:course), user: blocked) + create(:course_assessment_marketplace_access_block, user: blocked) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + expect(body['users'].first['blocked']).to be(true) + end + + it 'reports zero new when the marketplace is already open to everyone' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'open@preview.test')) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['openToEveryone']).to be(true) + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + end + + it 'returns zero matches for a user rule whose target is not eligible' do + create(:user, email: 'nobody@preview.test') # manages nothing + + preview(rule_type: 'user', email: 'nobody@preview.test') + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'rejects an email matching no user' do + preview(rule_type: 'user', email: 'ghost@preview.test') + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + + it 'rejects a rule that already exists' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'preview.test') + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:bad_request) + # Attribute name omitted: StubbedI18nBackend returns the raw key for + # `activerecord.attributes.*`, so full_messages can never render "Email domain" here. + expect(response.parsed_body['errors']).to include('already has a rule.') + end + + it 'denies a non-administrator' do + controller_sign_in(controller, create(:user)) + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + to raise_exception(CanCan::AccessDenied) + end + end + end +end diff --git a/spec/factories/course_assessment_marketplace_access_blocks.rb b/spec/factories/course_assessment_marketplace_access_blocks.rb new file mode 100644 index 00000000000..471edf6f26a --- /dev/null +++ b/spec/factories/course_assessment_marketplace_access_blocks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_access_block, + class: 'Course::Assessment::Marketplace::AccessBlock' do + association :user + association :creator, factory: :user + end +end diff --git a/spec/factories/course_assessment_marketplace_allowlist_rules.rb b/spec/factories/course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 00000000000..ae96fb09113 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule' do + # Default to a self-contained email-domain rule so the bare factory is valid under + # `factory_bot:lint`. Traits below override `rule_type` (and supply any needed association). + rule_type { :email_domain } + # Unique per invocation. Specs commit (use_transactional_fixtures is false), and email-domain + # rules are now unique per domain, so a hardcoded default would collide with the row committed + # by the previous run. + sequence(:email_domain) { |n| "domain-#{n}-#{SecureRandom.hex(3)}.test" } + + trait :for_user do + rule_type { :user } + association :user + end + trait :for_instance do + rule_type { :instance } + association :instance + end + trait :for_email_domain do + rule_type { :email_domain } + end + trait :everyone do + rule_type { :everyone } + end + end +end diff --git a/spec/factories/course_assessment_question_programming_test_cases.rb b/spec/factories/course_assessment_question_programming_test_cases.rb index b3696efb0b0..4bdb92a5132 100644 --- a/spec/factories/course_assessment_question_programming_test_cases.rb +++ b/spec/factories/course_assessment_question_programming_test_cases.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process — see the note in user_emails.rb. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" factory :course_assessment_question_programming_test_case, class: Course::Assessment::Question::ProgrammingTestCase do question { build(:course_assessment_question_programming) } - sequence(:identifier) { |n| "test_id_#{base_time}_#{n}" } + sequence(:identifier) { |n| "test_id_#{run_id}_#{n}" } expression { '' } expected { '' } test_case_type { :public_test } diff --git a/spec/factories/instances.rb b/spec/factories/instances.rb index c28441455dd..e4f4ce155ae 100644 --- a/spec/factories/instances.rb +++ b/spec/factories/instances.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process — see the note in user_emails.rb; host and name are both unique-constrained. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :host do |n| - "local-#{base_time}-#{n}.lvh.me" + "local-#{run_id}-#{n}.lvh.me" end factory :instance do - sequence(:name) { |n| "Instance-#{base_time}-#{n}" } + sequence(:name) { |n| "Instance-#{run_id}-#{n}" } host trait :with_learning_map_component_enabled do diff --git a/spec/factories/user_emails.rb b/spec/factories/user_emails.rb index a29f8d66e57..6801d742fca 100644 --- a/spec/factories/user_emails.rb +++ b/spec/factories/user_emails.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process. Specs commit (use_transactional_fixtures is false), so a bare timestamp + # collides whenever two rspec processes start within the same second, and the second process then + # fails User::Email's uniqueness validation. The timestamp is kept for tracing leaked rows. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :email do |n| - "user_#{n}@domain-#{base_time}-name.com" + "user_#{n}@domain-#{run_id}-name.com" end factory :user_email, class: User::Email.name do diff --git a/spec/models/course/assessment/marketplace/access_block_spec.rb b/spec/models/course/assessment/marketplace/access_block_spec.rb new file mode 100644 index 00000000000..bb97b9405ee --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_block_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessBlock, type: :model do + let!(:instance) { Instance.default } + + before { described_class.delete_all } + + with_tenant(:instance) do + describe 'validations' do + it 'is valid with a user and creator' do + block = build(:course_assessment_marketplace_access_block) + expect(block).to be_valid + end + + it 'rejects a second block for the same user' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + duplicate = build(:course_assessment_marketplace_access_block, user: user) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + end + + describe '.blocked?' do + it 'is false for a nil user' do + expect(described_class.blocked?(nil)).to be(false) + end + + it 'is false when the user has no block' do + expect(described_class.blocked?(create(:user))).to be(false) + end + + it 'is true when the user has a block' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked?(user)).to be(true) + end + end + + describe '.blocked_user_ids' do + it 'returns the user ids of all blocks' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked_user_ids).to contain_exactly(user.id) + end + end + + describe 'when the admin who issued the block is destroyed' do + # `creator_id` is NOT NULL and FKs to users, so this raised PG::ForeignKeyViolation. The block + # must survive — it is a decision about the BLOCKED person, not about its author — so + # authorship is reassigned to the Deleted user rather than the row being destroyed. + it 'keeps the block and reassigns it to the Deleted user' do + creator = create(:administrator) + block = create(:course_assessment_marketplace_access_block, creator: creator) + + expect { ActsAsTenant.without_tenant { creator.destroy } }. + not_to(change { described_class.count }) + expect(block.reload.creator_id).to eq(User::DELETED_USER_ID) + end + end + + describe 'when the blocked user is destroyed' do + # The blocks table has an FK to users with no ON DELETE, so without a `dependent:` association + # on User the admin panel's delete-user action dies with PG::ForeignKeyViolation. + it 'destroys the block instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/access_list_query_spec.rb b/spec/models/course/assessment/marketplace/access_list_query_spec.rb new file mode 100644 index 00000000000..fa233a2563d --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_list_query_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessListQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs here commit (use_transactional_fixtures is false repo-wide), so rows from previous runs + # persist. User::Email additionally enforces uniqueness, so the allow-listed-domain addresses below + # must be cleared too or re-creating them raises RecordInvalid. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern silently leaves rows behind that + # collide on the next run (this bit Task 1). + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + end + + with_tenant(:instance) do + it 'excludes a baseline user when no rule matches them' do + create(:course_manager, course: create(:course)) # manager, but no allow-list rule + # System admins are listed unconditionally (they bypass every gate), and the test DB always + # holds at least the seeded one — so this asserts on the non-admin rows. + expect(described_class.new.rows.reject(&:system_admin?)).to be_empty + end + + it 'includes a manager cleared by a user rule, annotated with course count and rule' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.course_count).to eq(1) + expect(row.instance_role).to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + expect(row.blocked?).to be(false) + end + + it 'includes an instance instructor (managing no course) under an everyone rule' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to eq('instructor') + # An everyone rule is a page-level mode, not a per-row reason: rows carry no scoped rules. + expect(row.allowed_by_rules).to be_empty + end + + it 'includes a manager cleared by an email-domain rule' do + user = create(:user, email: 'teacher@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['email_domain']) + end + + it 'excludes a manager whose only allow-listed-domain email is unconfirmed' do + user = create(:user) # has a confirmed primary email at a non-matching domain + create(:course_manager, course: create(:course), user: user) + create(:user_email, :unconfirmed, email: 'pending@schools.gov.sg', + user: user, primary: false) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + expect(described_class.new.rows.map(&:user)).not_to include(user) + end + + it 'includes a manager cleared by an instance rule' do + cu = create(:course_manager, course: create(:course)) + # cu.user has a normal InstanceUser in the default instance via the after_create callback, + # so an instance rule for the default instance clears them. + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['instance']) + end + + it 'does not include a non-baseline user even when a user rule targets them' do + cu = create(:course_student, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'keeps a blocked user in the list, flagged with the block id' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.blocked?).to be(true) + expect(row.block_id).to eq(block.id) + end + + # Without this, dropping the `instance_id` filter from RuleMatchQuery#matched_instance_members + # would still pass every other example — the instance-rule test above uses only one instance. + it 'does not clear a user via an instance rule scoped to a different instance' do + rule_instance = create(:instance) + member_instance = create(:instance) + cu = create(:course_manager, course: create(:course)) + ActsAsTenant.with_tenant(member_instance) do + create(:instance_user, :instructor, user: cu.user, instance: member_instance) + end + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: rule_instance) + + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'lists every rule matching a user, not just the highest-precedence one' do + user = create(:user, email: 'both@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to contain_exactly(user_rule.id, domain_rule.id) + end + + it 'orders a row\'s rules by rule id' do + user = create(:user, email: 'ordered@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + first = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + second = create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to eq([first.id, second.id]) + end + + it 'lists a blocked user whose matching rule was removed, so the block stays reachable' do + cu = create(:course_manager, course: create(:course)) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + # No allow-list rule matches them at all — without the block they would not be listed. + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules).to be_empty + expect(row.block_id).to eq(block.id) + expect(row.blocked?).to be(true) + end + + it 'lists a blocked user who is no longer baseline-eligible at all' do + user = create(:user) # manages nothing, staff nowhere + create(:course_assessment_marketplace_access_block, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to be_nil + end + + describe '#allowed_user_ids' do + it 'returns baseline users cleared by a rule, and excludes uncleared ones' do + cleared = create(:course_manager, course: create(:course)) + uncleared = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: cleared.user) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(cleared.user.id) + expect(ids).not_to include(uncleared.user.id) + end + + it 'still counts a blocked user as allowed — a block is not an allow-list decision' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + create(:course_assessment_marketplace_access_block, user: cu.user) + + expect(described_class.new.allowed_user_ids).to include(cu.user.id) + end + + # Guards the `everyone?` branch: without it, collapsing the method to `rules_by_user.keys` + # would silently regress open-to-everyone into "only explicitly matched users". + it 'includes every baseline user when an everyone rule exists, not only rule-matched ones' do + first = create(:course_manager, course: create(:course)) + second = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(first.user.id, second.user.id) + end + end + + describe 'system administrators' do + it 'lists an admin who manages nothing and matches no rule' do + admin = create(:administrator) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row).not_to be_nil + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules).to be_empty + end + + it 'keeps listing an admin as blocked when a block exists' do + # A block row cannot actually revoke a sysadmin's bypass, but an orphaned one must stay + # visible and clearable — same contract as any other blocked user. + admin = create(:administrator) + create(:course_assessment_marketplace_access_block, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.blocked?).to be(true) + end + + it 'still records the rules that match an admin' do + admin = create(:administrator) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + end + + it 'does not mark a non-admin as one' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.system_admin?).to be(false) + end + end + + describe '#summary' do + it 'counts effective access and blocked separately, and reports the mode' do + active = create(:course_manager, course: create(:course)) + blocked = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: active.user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: blocked.user) + create(:course_assessment_marketplace_access_block, user: blocked.user) + + # Admins are always listed and always count as having access, and the test DB carries at + # least the seeded one, so the expectation is relative to however many exist. + admins = User.administrator.count + summary = described_class.new.summary + expect(summary[:total_with_access]).to eq(1 + admins) + expect(summary[:total_blocked]).to eq(1) + expect(summary[:open_to_everyone]).to be(false) + end + + it 'reports open_to_everyone when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new.summary[:open_to_everyone]).to be(true) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb new file mode 100644 index 00000000000..17b37957f7c --- /dev/null +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do + let!(:instance) { Instance.default } + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + User::Email.delete_all + end + + with_tenant(:instance) do + describe 'validations' do + it 'requires user for a user rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: nil) + expect(rule).not_to be_valid + expect(rule.errors[:user]).to be_present + end + + it 'requires email_domain for an email_domain rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: nil) + expect(rule).not_to be_valid + expect(rule.errors[:email_domain]).to be_present + end + + it 'requires instance for an instance rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: nil) + expect(rule).not_to be_valid + expect(rule.errors[:instance]).to be_present + end + + it 'is valid as an everyone rule with no target fields' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(rule).to be_valid + end + + it 'allows only one everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + duplicate = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:rule_type]).to be_present + end + end + + describe '.grants_access?' do + it 'is false for a nil user' do + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'is false when no rule matches' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'nomatch.example') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an explicit user rule' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'matches an instance rule when the user belongs to that instance' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match an instance rule for another instance under the current tenant' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) { InstanceUser.create!(user: user) } + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, + instance: other_instance) + # `user.instance_users` is tenant-scoped (acts_as_tenant), so an instance rule grants + # access only while browsing the allow-listed instance — membership elsewhere is invisible. + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an email-domain rule case-insensitively' do + user_email = create(:user_email, email: 'testuser@Schools.GOV.sg') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match a different email domain' do + user_email = create(:user_email, email: 'testuser@other.edu') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches any user when an everyone rule exists' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'is false for a nil user even when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'keeps granting a user rule after the user replaces their email (access is by user_id, not email)' do + user = create(:user) + original_email = user.email + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + + # Retire the original email and attach a brand-new one — the same person, different address. + user.emails.where(email: original_email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not grant access via a different user\'s rule after this user replaces their email' do + user = create(:user) + other_user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: other_user) + + user.emails.where(email: user.email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(false) + end + end + + describe 'email resolution for a user rule' do + it 'resolves a confirmed email to the owning user' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'teacher@school.edu') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is case-insensitive on the entered email' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: ' Teacher@School.EDU ') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is invalid with a clear message when no user has that email' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to include('No user with that email.') + end + + it 'does not also add a user-presence error when the email fails to resolve' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to eq(['No user with that email.']) + end + end + + describe 'duplicate rules' do + it 'rejects a second user rule for the same user' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to include('already has a rule.') + end + + it 'allows a user rule for a different user' do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: create(:user)) + expect(build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: create(:user))).to be_valid + end + + it 'rejects a second instance rule for the same instance' do + other_instance = create(:instance) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:instance_id]).to include('already has a rule.') + end + + it 'rejects a second email-domain rule for the same domain' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has a rule.') + end + + it 'treats a differently-cased domain as the same rule' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' DUPES.TEST ') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has a rule.') + end + + it 'normalizes the stored domain to stripped lowercase' do + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' MiXeD.TEST ') + expect(rule.reload.email_domain).to eq('mixed.test') + end + + # A user rule whose email resolves to nobody keeps user_id NULL, and Rails checks uniqueness + # as `user_id IS NULL` — which matches every instance and email-domain rule unless the check + # is scoped to rule_type. Unscoped, the admin gets a bogus "already has a rule." stacked on + # top of the real reason. (Verified by mutation: dropping `scope: :rule_type` fails this.) + it 'does not report a duplicate for an unresolvable email when other rule types exist' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: create(:instance)) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: nil, email: 'nobody@nowhere.test') + + expect(rule).not_to be_valid + expect(rule.errors[:user_id]).to be_empty + expect(rule.errors[:base]).to include('No user with that email.') + end + end + + describe 'when the targeted user is destroyed' do + # Same FK trap as the access-blocks table: a user rule pins the user row, so deleting an + # allow-listed user from the admin panel raised PG::ForeignKeyViolation. + it 'destroys the rule instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + + it 'leaves rules that do not target that user alone' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'keeps.test') + # Created under the tenant, destroyed without one (as the admin panel does): building a + # user inside `without_tenant` fails its own `instance_users` validation. + bystander = create(:user) + + expect { ActsAsTenant.without_tenant { bystander.destroy } }. + not_to(change { described_class.count }) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/rule_match_query_spec.rb b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb new file mode 100644 index 00000000000..9fb78a59b0f --- /dev/null +++ b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RuleMatchQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs commit (use_transactional_fixtures is false repo-wide), so rows from previous runs persist + # and User::Email enforces uniqueness. Clear the fixed-domain addresses this file creates. + # + # The pattern must be LOWER()'d and must not anchor the '@': the uniqueness index is on + # `lower(email)` while SQL LIKE is case-sensitive, and one example deliberately uses a SUBDOMAIN + # (someone@sub.match-query.test). A '%@match-query.test' pattern misses both, leaving rows behind + # that collide on the next run. + before do + User::Email.where('LOWER(email) LIKE ?', '%match-query.test').delete_all + end + + with_tenant(:instance) do + describe 'a user rule' do + it 'matches only the targeted user, and only within the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([target.id, other.id])). + to eq(Set[target.id]) + end + + it 'returns nothing when the targeted user is outside the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([other.id])).to be_empty + end + end + + describe 'an instance rule' do + it 'matches candidates belonging to that instance, across tenants' do + member = create(:user) + outsider = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: member, instance: other_instance) + end + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(described_class.new(rule).user_ids_within([member.id, outsider.id])). + to eq(Set[member.id]) + end + end + + describe 'an email-domain rule' do + it 'matches a candidate holding a confirmed email at that domain' do + user = create(:user, email: 'teacher@match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'matches case-insensitively on both the rule and the address' do + user = create(:user, email: 'head@match-query.test') + # Force the stored address to mixed case directly. `create(:user, email: 'HEAD@...')` + # raises RecordNotUnique even on a fresh address: the write path inserts both the given + # and the normalized form, and the two collide under the `lower(email)` unique index. + # Legacy rows can still hold mixed case, and the query's LOWER(SPLIT_PART(...)) on the + # address side exists for exactly them — so this is the only way to reach that branch. + User::Email.where(user_id: user.id). + where('LOWER(email) = ?', 'head@match-query.test'). + update_all(email: 'HEAD@MATCH-QUERY.TEST') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'Match-Query.TEST') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'ignores an unconfirmed address at that domain' do + user = create(:user) # confirmed primary email at a non-matching domain + create(:user_email, :unconfirmed, email: 'pending@match-query.test', + user: user, primary: false) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + + it 'does not match a different domain that merely shares a suffix' do + user = create(:user, email: 'someone@sub.match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + end + + describe 'an everyone rule' do + it 'matches the whole candidate set' do + a = create(:user) + b = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + + expect(described_class.new(rule).user_ids_within([a.id, b.id])).to eq(Set[a.id, b.id]) + end + end + + it 'returns an empty set for an empty candidate list without querying' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new(rule).user_ids_within([])).to be_empty + end + + it 'treats an unsaved rule identically to a persisted one' do + target = create(:user) + unsaved = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + persisted = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: target) + + expect(described_class.new(unsaved).user_ids_within([target.id])). + to eq(described_class.new(persisted).user_ids_within([target.id])) + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 1dabaf126c8..4564124a913 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -19,6 +19,7 @@ context 'when the user is a course manager' do let(:course_user) { create(:course_manager, course: course) } let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } @@ -37,5 +38,79 @@ let(:user) { course_user.user } it { is_expected.not_to be_able_to(:access_marketplace, course) } end + + context 'when the user is a course manager but is not allow-listed' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + # Load-bearing at the ability level: without the explicit `cannot`, the blanket + # `can :manage, Course` a manager holds would satisfy `:access_marketplace`. + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an observer here but manages another course (person-level access)' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before do + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + end + + context 'when an allow-listed user manages no course at all' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course but is allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + # Proves the second baseline branch: eligible via instance role, not via managing a course. + it { is_expected.to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course and is not allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when an eligible, allow-listed manager is individually blocked' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + create(:course_assessment_marketplace_access_block, user: user) + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + + it 'regains access once the block is removed' do + Course::Assessment::Marketplace::AccessBlock.where(user_id: user.id).delete_all + expect(Ability.new(user, course, course_user)).to be_able_to(:access_marketplace, course) + end + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b7109e39b9d..0e8b01d6684 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -44,6 +44,67 @@ end end + describe '#course_manager_or_owner?' do + let(:user) { create(:user) } + + it 'is true when the user manages a course' do + create(:course_manager, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user owns a course' do + create(:course_owner, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user manages a course in a different instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:course_manager, course: create(:course), user: user) + end + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is false when the user only has non-manager course roles' do + create(:course_student, course: create(:course), user: user) + create(:course_observer, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(false) + end + + it 'is false when the user is in no course' do + expect(user.course_manager_or_owner?).to be(false) + end + end + + describe '#instance_instructor_or_administrator?' do + # Eager: a lazy `let` would first run `create(:user)` inside the `with_tenant(other_instance)` + # block below, and `after_create :create_instance_user` would then give the user a normal + # InstanceUser in *that* instance — colliding with the instructor/administrator one we create. + let!(:user) { create(:user) } + + it 'is true when the user is an instructor in some instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is true when the user is an administrator in some instance' do + another_instance = create(:instance) + ActsAsTenant.with_tenant(another_instance) do + create(:instance_administrator, user: user, instance: another_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is false when the user is only a normal instance member' do + # `create(:user)` already gives a normal InstanceUser in the default instance via the + # after_create callback; there is no instructor/administrator membership anywhere. + expect(user.instance_instructor_or_administrator?).to be(false) + end + end + describe '#emails' do let(:user) { create(:user, emails_count: 5) } it 'unsets other email as primary when a new email is assigned' do