Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller
load_and_authorize_resource :allowlist_rule,
class: 'Course::Assessment::Marketplace::AllowlistRule',
parent: false

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 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)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,26 @@ module Course::AssessmentMarketplaceAbilityComponent

def define_permissions
allow_admins_publish_to_marketplace if user&.administrator?
allow_managers_access_marketplace if course_user&.manager_or_owner?
if course_user&.manager_or_owner?
if marketplace_visible_to_user?
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.
cannot :access_marketplace, Course, id: course.id
end
end
super
end

private

def marketplace_visible_to_user?
user&.administrator? || Course::Assessment::Marketplace::AllowlistRule.grants_access?(user)
end

def allow_admins_publish_to_marketplace
can :publish_to_marketplace, Course::Assessment
end
Expand Down
38 changes: 38 additions & 0 deletions app/models/course/assessment/marketplace/allowlist_rule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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

validates :user, presence: true, if: :rule_type_user?
validates :instance, presence: true, if: :rule_type_instance?
validates :email_domain, presence: true, 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).
# @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
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
json.id rule.id
json.ruleType rule.rule_type
json.userId rule.user_id
json.userName rule.user&.name
json.instanceId rule.instance_id
json.instanceName rule.instance&.name
json.emailDomain rule.email_domain
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions client/app/api/system/Admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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 { AdminStats, UserListData } from 'types/users';

import BaseSystemAPI from '../Base';
Expand Down Expand Up @@ -173,4 +177,54 @@ export default class AdminAPI extends BaseSystemAPI {
getDeploymentInfo(): Promise<AxiosResponse<DeploymentInfo>> {
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<AxiosResponse<AllowlistRuleData>> {
return this.client.post(
`${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`,
{
allowlist_rule: {
rule_type: params.ruleType,
user_id: params.userId,
instance_id: params.instanceId,
email_domain: params.emailDomain,
},
},
);
}

/**
* 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<AxiosResponse<{ id: number }>> {
return this.client.post(
`${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`,
{ allowlist_rule: { rule_type: 'everyone' } },
);
}

/**
* Deletes a marketplace allow-list rule.
*/
deleteMarketplaceAllowlistRule(id: number): Promise<AxiosResponse> {
return this.client.delete(
`${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`,
);
}
}
10 changes: 10 additions & 0 deletions client/app/bundles/system/admin/admin/AdminNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Category,
Chat,
Group,
Storefront,
} from '@mui/icons-material';

import useTranslation from 'lib/hooks/useTranslation';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -64,6 +69,11 @@ const AdminNavigator = (): JSX.Element => {
title: t(translations.courses),
path: '/admin/courses',
},
{
icon: <Storefront />,
title: t(translations.marketplace),
path: '/admin/marketplace_allowlist_rules',
},
{
icon: <Chat />,
title: t(translations.getHelp),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useState } from 'react';
import { defineMessages } from 'react-intl';
import { Alert, Button } from '@mui/material';

import Prompt from 'lib/components/core/dialogs/Prompt';
import useTranslation from 'lib/hooks/useTranslation';

interface Props {
openToEveryone: boolean;
onOpenToEveryone: () => Promise<void>;
onRestrict: () => Promise<void>;
}

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 course managers. The rules below are preserved but inactive.',
},
openButton: {
id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openButton',
defaultMessage: 'Open to everyone',
},
restrictButton: {
id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictButton',
defaultMessage: 'Restrict to scoped rules',
},
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 course managers and owners in every instance. 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. Managers 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<void> => {
setSubmitting(true);
try {
await (openToEveryone ? onRestrict() : onOpenToEveryone());
setIsConfirmOpen(false);
} finally {
setSubmitting(false);
}
};

return (
<>
<Alert
action={
<Button
color="inherit"
disabled={submitting}
onClick={(): void => setIsConfirmOpen(true)}
size="small"
>
{openToEveryone
? t(translations.restrictButton)
: t(translations.openButton)}
</Button>
}
className="mb-4"
severity={openToEveryone ? 'success' : 'info'}
>
{openToEveryone
? t(translations.everyoneTitle)
: t(translations.scopedTitle)}
</Alert>

<Prompt
onClickPrimary={handleConfirm}
onClose={(): void => 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)}
</Prompt>
</>
);
};

export default MarketplaceAllowlistModeBanner;
Loading