Skip to content

Commit 69326c2

Browse files
committed
feat(marketplace): access audit list and per-user block controls
Add the access audit section to the allow-list page: an audit list of everyone with effective access (filterable by status and granting rule), block/unblock controls per user, and the rule-match counts that flag allow-list rules which currently grant access to nobody.
1 parent c7349a7 commit 69326c2

10 files changed

Lines changed: 2423 additions & 1 deletion

File tree

client/app/api/system/Admin.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
} from 'types/course/announcements';
66
import { CourseListData } from 'types/system/courses';
77
import { InstanceListData, InstancePermissions } from 'types/system/instances';
8-
import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess';
8+
import {
9+
AllowlistRulePreviewData,
10+
MarketplaceAccessData,
11+
} from 'types/system/marketplaceAccess';
912
import {
1013
AllowlistRuleData,
1114
AllowlistRuleFormData,
@@ -248,4 +251,34 @@ export default class AdminAPI extends BaseSystemAPI {
248251
`${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`,
249252
);
250253
}
254+
255+
/**
256+
* Fetches the marketplace access audit list (everyone with effective access, blocked flagged).
257+
*/
258+
indexMarketplaceAccess(): Promise<AxiosResponse<MarketplaceAccessData>> {
259+
return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_access`);
260+
}
261+
262+
/**
263+
* Blocks (disables) a user's marketplace access. Returns the created block's id.
264+
*/
265+
blockMarketplaceUser(
266+
userId: number,
267+
): Promise<AxiosResponse<{ id: number; userId: number }>> {
268+
return this.client.post(
269+
`${AdminAPI.#urlPrefix}/marketplace_access_blocks`,
270+
{
271+
user_id: userId,
272+
},
273+
);
274+
}
275+
276+
/**
277+
* Removes a block, re-enabling the user's marketplace access.
278+
*/
279+
unblockMarketplaceUser(blockId: number): Promise<AxiosResponse> {
280+
return this.client.delete(
281+
`${AdminAPI.#urlPrefix}/marketplace_access_blocks/${blockId}`,
282+
);
283+
}
251284
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { useState } from 'react';
2+
import { defineMessages } from 'react-intl';
3+
import { FilterList } from '@mui/icons-material';
4+
import {
5+
Badge,
6+
Button,
7+
Checkbox,
8+
Divider,
9+
FormControlLabel,
10+
IconButton,
11+
Menu,
12+
Tooltip,
13+
Typography,
14+
} from '@mui/material';
15+
16+
import useTranslation from 'lib/hooks/useTranslation';
17+
18+
export interface RuleOption {
19+
id: number;
20+
label: string;
21+
}
22+
23+
interface Props {
24+
showActive: boolean;
25+
showBlocked: boolean;
26+
onToggleActive: () => void;
27+
onToggleBlocked: () => void;
28+
/** Empty when the marketplace is open to everyone — the rule group is then meaningless. */
29+
ruleOptions: RuleOption[];
30+
/**
31+
* Ids the admin has UNchecked. Tracking exclusions rather than inclusions means a newly added
32+
* rule is filtered in by default, with no state to resynchronise when `ruleOptions` changes.
33+
*/
34+
uncheckedRuleIds: Set<number>;
35+
onToggleRule: (id: number) => void;
36+
onClear: () => void;
37+
}
38+
39+
const translations = defineMessages({
40+
trigger: {
41+
id: 'system.admin.admin.MarketplaceAccessFilter.trigger',
42+
defaultMessage: 'Filter',
43+
},
44+
status: {
45+
id: 'system.admin.admin.MarketplaceAccessFilter.status',
46+
defaultMessage: 'Status',
47+
},
48+
active: {
49+
id: 'system.admin.admin.MarketplaceAccessFilter.active',
50+
defaultMessage: 'Active',
51+
},
52+
blocked: {
53+
id: 'system.admin.admin.MarketplaceAccessFilter.blocked',
54+
defaultMessage: 'Blocked',
55+
},
56+
allowedByRule: {
57+
id: 'system.admin.admin.MarketplaceAccessFilter.allowedByRule',
58+
defaultMessage: 'Allowed by rule',
59+
},
60+
clearAll: {
61+
id: 'system.admin.admin.MarketplaceAccessFilter.clearAll',
62+
defaultMessage: 'Clear all',
63+
},
64+
});
65+
66+
/**
67+
* A bespoke filter popover rather than the shared table's built-in per-column filtering
68+
* (`filterable` + `filterProps`). The built-in machinery could in fact handle both the array-valued
69+
* rules column (via `filterProps.getValue`/`shouldInclude`) and the synthetic System-admin option
70+
* (`getValue` is arbitrary) — those two objections are false. The real reason is that built-in
71+
* filtering is table-internal in the three respects this feature needs externalised:
72+
*
73+
* 1. The filtered result never leaves the table. `TableTemplate` exposes no callback for it, and
74+
* the count feeds pagination internally — yet the section renders a
75+
* "Filtered: N with access · M blocked" line that needs the filtered set outside the table.
76+
* (Decisive.)
77+
* 2. Render location. `MuiFilterMenu` renders inside a column header; the design is one filter
78+
* icon in the toolbar spanning Status AND rules, with a single badge and one "Clear all" —
79+
* built-in yields two header icons, two badges, two independent clears.
80+
* 3. Checked-by-default is unreachable. In the built-in filter the selection array IS the filter,
81+
* so a both-on Status default would need the selection inverted, putting checkmarks on exactly
82+
* the wrong items. This component instead tracks EXCLUSIONS, so a newly added rule filters in
83+
* by default with no state to resynchronise.
84+
*/
85+
const MarketplaceAccessFilter = ({
86+
showActive,
87+
showBlocked,
88+
onToggleActive,
89+
onToggleBlocked,
90+
ruleOptions,
91+
uncheckedRuleIds,
92+
onToggleRule,
93+
onClear,
94+
}: Props): JSX.Element => {
95+
const { t } = useTranslation();
96+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
97+
98+
const activeCount =
99+
(showActive ? 0 : 1) + (showBlocked ? 0 : 1) + uncheckedRuleIds.size;
100+
101+
const label = t(translations.trigger);
102+
103+
return (
104+
<>
105+
<Tooltip title={label}>
106+
<Badge badgeContent={activeCount} className="shrink-0" color="primary">
107+
<IconButton
108+
aria-label={label}
109+
color="primary"
110+
onClick={(event): void => setAnchor(event.currentTarget)}
111+
>
112+
<FilterList />
113+
</IconButton>
114+
</Badge>
115+
</Tooltip>
116+
117+
<Menu
118+
anchorEl={anchor}
119+
onClose={(): void => setAnchor(null)}
120+
open={Boolean(anchor)}
121+
>
122+
<div className="flex min-w-[16rem] flex-col px-4 py-2">
123+
<Typography color="text.secondary" variant="caption">
124+
{t(translations.status)}
125+
</Typography>
126+
127+
<FormControlLabel
128+
control={
129+
<Checkbox checked={showActive} onChange={onToggleActive} />
130+
}
131+
label={t(translations.active)}
132+
/>
133+
134+
<FormControlLabel
135+
control={
136+
<Checkbox checked={showBlocked} onChange={onToggleBlocked} />
137+
}
138+
label={t(translations.blocked)}
139+
/>
140+
141+
{ruleOptions.length > 0 && (
142+
<>
143+
<Divider className="my-2" />
144+
145+
<Typography color="text.secondary" variant="caption">
146+
{t(translations.allowedByRule)}
147+
</Typography>
148+
149+
{ruleOptions.map((option) => (
150+
<FormControlLabel
151+
key={option.id}
152+
control={
153+
<Checkbox
154+
checked={!uncheckedRuleIds.has(option.id)}
155+
onChange={(): void => onToggleRule(option.id)}
156+
/>
157+
}
158+
label={option.label}
159+
/>
160+
))}
161+
</>
162+
)}
163+
164+
<Button className="mt-2 self-end" onClick={onClear} size="small">
165+
{t(translations.clearAll)}
166+
</Button>
167+
</div>
168+
</Menu>
169+
</>
170+
);
171+
};
172+
173+
export default MarketplaceAccessFilter;

0 commit comments

Comments
 (0)