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
6 changes: 3 additions & 3 deletions app/models/course/assessment/marketplace/allowlist_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord
# 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.' },
validates :user_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' },
if: :rule_type_user?
validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' },
validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' },
if: :rule_type_instance?
validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has a rule.' },
validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has the same 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?
Expand Down
75 changes: 75 additions & 0 deletions client/app/api/system/Admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
} from 'types/course/announcements';
import { CourseListData } from 'types/system/courses';
import { InstanceListData, InstancePermissions } from 'types/system/instances';
import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess';
import {
AllowlistRuleData,
AllowlistRuleFormData,
} from 'types/system/marketplaceAllowlist';
import { AdminStats, UserListData } from 'types/users';

import BaseSystemAPI from '../Base';
Expand Down Expand Up @@ -173,4 +178,74 @@ 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,
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<AxiosResponse<AllowlistRulePreviewData>> {
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<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,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<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 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<void> => {
setSubmitting(true);
try {
await (openToEveryone ? onRestrict() : onOpenToEveryone());
setIsConfirmOpen(false);
} finally {
setSubmitting(false);
}
};

return (
<>
<Alert
action={
<FormControlLabel
control={
<Switch
checked={openToEveryone}
disabled={submitting}
onChange={(): void => setIsConfirmOpen(true)}
/>
}
label={
<Typography
className="whitespace-nowrap"
color="inherit"
variant="body2"
>
{t(translations.toggleLabel)}
</Typography>
}
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)}
</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