From 0f9bfa46cd8af4e358f3b53cc3581f25bfb145aa Mon Sep 17 00:00:00 2001 From: bh0fer Date: Thu, 6 Aug 2026 17:29:50 +0200 Subject: [PATCH 1/7] first draft of permission control panel --- src/api/document.ts | 2 + src/api/documentRoot.ts | 20 +- src/api/permission.ts | 12 ++ src/components/Admin/AdminPanel/index.tsx | 6 +- .../Admin/PermissionsControl/index.tsx | 177 ++++++++++++++++++ .../PermissionsControl/styles.module.scss | 19 ++ .../PermissionsPanel/AccessBadge/index.tsx | 2 +- src/components/PermissionsPanel/index.tsx | 2 +- .../AddDynamicDocumentRoot/index.tsx | 2 +- src/models/DocumentRoot.ts | 9 + src/models/StudentGroup.ts | 8 +- src/siteConfig/markdownPluginConfigs.ts | 4 + src/stores/DocumentRootStore.ts | 9 +- src/stores/PermissionStore.ts | 63 +++++-- .../ViewStores/PermissionsControlView.ts | 90 +++++++++ src/stores/ViewStores/index.ts | 5 +- src/stores/rootStore.ts | 1 + 17 files changed, 390 insertions(+), 41 deletions(-) create mode 100644 src/components/Admin/PermissionsControl/index.tsx create mode 100644 src/components/Admin/PermissionsControl/styles.module.scss create mode 100644 src/stores/ViewStores/PermissionsControlView.ts diff --git a/src/api/document.ts b/src/api/document.ts index acd58ad9d..11a216829 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -141,6 +141,7 @@ export interface TaskableDocumentMapping extends AssessableDataMapping { } export interface TypeDataMapping extends TaskableDocumentMapping, ContainerTypeDataMapping { + ['_unknown_']: { name: string }; ['code']: CodeData; // TODO: rename to `code_version` ['script_version']: ScriptVersionData; @@ -187,6 +188,7 @@ null as unknown as EnsureAllTaskable; export interface TypeModelMapping extends TaskableTypeModelMapping, ContainerTypeModelMapping, AssessableTypeModelMapping { + ['_unknown_']: any; ['code']: Code; // TODO: rename to `code_version`? ['script_version']: ScriptVersion; diff --git a/src/api/documentRoot.ts b/src/api/documentRoot.ts index 887adb2c7..70ed629a0 100644 --- a/src/api/documentRoot.ts +++ b/src/api/documentRoot.ts @@ -43,6 +43,12 @@ export function find(id: string, signal: AbortSignal): AxiosPromise { - const params = new URLSearchParams(); - if (ignoreMissingRoots) { - params.append('ignoreMissingRoots', '1'); - } - ids.forEach((id) => params.append('ids', id)); - if (documentType) { - params.append('type', documentType); - } - const data: { - documentRootIds: string[]; - ignoreMissingRoots?: boolean; - type?: string; - } = { + const data: FindManyForData = { documentRootIds: ids }; if (ignoreMissingRoots) { diff --git a/src/api/permission.ts b/src/api/permission.ts index e7f8f7688..ba43f76c7 100644 --- a/src/api/permission.ts +++ b/src/api/permission.ts @@ -24,6 +24,8 @@ export interface UserPermission extends UserPermissionBase { export type Permissions = { id: string; + access: Access; + sharedAccess: Access; userPermissions: UserPermissionBase[]; groupPermissions: GroupPermissionBase[]; }; @@ -69,3 +71,13 @@ export function deleteGroupPermission(id: string, signal: AbortSignal): AxiosPro export function permissionsFor(documentRootId: string, signal: AbortSignal): AxiosPromise { return api.get(`/documentRoots/${documentRootId}/permissions`, { signal }); } + +export function documentRootPermissions( + documentRootIds: string[], + signal: AbortSignal +): AxiosPromise { + const data = { + documentRootIds: documentRootIds + }; + return api.post(`/documentRoots/permissions`, data, { signal }); +} diff --git a/src/components/Admin/AdminPanel/index.tsx b/src/components/Admin/AdminPanel/index.tsx index 3e2cef3d8..1ba322d56 100644 --- a/src/components/Admin/AdminPanel/index.tsx +++ b/src/components/Admin/AdminPanel/index.tsx @@ -9,6 +9,7 @@ import StudentGroupPanel from '@tdev-components/Admin/StudentGroupPanel'; import UserTable from '@tdev-components/Admin/UserTable'; import AllowedActions from '../AllowedActions'; import CreateUser from '../CreateUser'; +import PermissionsControl from '../PermissionsControl'; const AdminPanel = observer(() => { const userStore = useStore('userStore'); @@ -24,13 +25,16 @@ const AdminPanel = observer(() => { } return (
- + + + + diff --git a/src/components/Admin/PermissionsControl/index.tsx b/src/components/Admin/PermissionsControl/index.tsx new file mode 100644 index 000000000..cb6e694e8 --- /dev/null +++ b/src/components/Admin/PermissionsControl/index.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import Button from '@tdev-components/shared/Button'; +import Badge from '@tdev-components/shared/Badge'; +import Link from '@docusaurus/Link'; +import CopyBadge from '@tdev-components/shared/CopyBadge'; +import { reaction } from 'mobx'; +import AccessBadge, { AccessIcon } from '@tdev-components/PermissionsPanel/AccessBadge'; +import Icon from '@mdi/react'; +import { SIZE_S, SIZE_XS } from '@tdev-components/shared/iconSizes'; +import { + mdiAccount, + mdiAccountCancel, + mdiAccountGroup, + mdiAccountMultipleRemove, + mdiAccountRemove, + mdiSync, + mdiSyncCircle +} from '@mdi/js'; +import { ApiState } from '@tdev-stores/iStore'; +import Card from '@tdev-components/shared/Card'; +import { Confirm } from '@tdev-components/shared/Button/Confirm'; + +interface Props {} + +const PermissionsControl = observer((props: Props) => { + const docRootStore = useStore('documentRootStore'); + const permissionStore = useStore('permissionStore'); + const viewStore = useStore('viewStore'); + const view = viewStore.permissionControl; + + React.useEffect(() => { + const loadDocRoots = (ids: string[]) => { + permissionStore.loadAllPermissions(ids).catch((err) => { + console.error('Error loading permissions:', err); + }); + }; + loadDocRoots(view.relevantDocumentRootIds); + const dispose = reaction( + () => view.relevantDocumentRootIds, + (relevantDocumentRootIds) => { + loadDocRoots(relevantDocumentRootIds); + } + ); + return () => { + dispose(); + }; + }, []); + + return ( +
+ Filter} classNames={{ card: clsx(styles.actions) }}> +
+ {view.documentTypes.map((docType, idx) => ( +
+
+ ); +}); + +export default PermissionsControl; diff --git a/src/components/Admin/PermissionsControl/styles.module.scss b/src/components/Admin/PermissionsControl/styles.module.scss new file mode 100644 index 000000000..ffb957de9 --- /dev/null +++ b/src/components/Admin/PermissionsControl/styles.module.scss @@ -0,0 +1,19 @@ +.adminPermission { + .actions { + margin-bottom: 1em; + .typeFilter { + flex-wrap: wrap; + margin-bottom: 1em; + .docTypeButton { + flex-grow: 0; + flex-shrink: 1; + flex-basis: 10em; + font-family: var(--ifm-font-family-monospace); + } + } + } + .docsTree { + max-height: 75vh; + overflow-y: auto; + } +} diff --git a/src/components/PermissionsPanel/AccessBadge/index.tsx b/src/components/PermissionsPanel/AccessBadge/index.tsx index c85d4dd05..b30319ea2 100644 --- a/src/components/PermissionsPanel/AccessBadge/index.tsx +++ b/src/components/PermissionsPanel/AccessBadge/index.tsx @@ -7,7 +7,7 @@ import Icon from '@mdi/react'; import { ROAccess, RWAccess } from '@tdev-models/helpers/accessPolicy'; import { mdiEye, mdiEyeOff, mdiSquareEditOutline } from '@mdi/js'; const SIZE = 0.8; -const AccessIcon = (access: Access) => { +export const AccessIcon = (access?: Access) => { if (RWAccess.has(access)) { return mdiSquareEditOutline; } diff --git a/src/components/PermissionsPanel/index.tsx b/src/components/PermissionsPanel/index.tsx index 4a07475df..e8012e836 100644 --- a/src/components/PermissionsPanel/index.tsx +++ b/src/components/PermissionsPanel/index.tsx @@ -109,7 +109,7 @@ const PermissionsPanel = observer((props: Props) => { keepTooltipInside="#__docusaurus" modal={isMobileView} onOpen={action(() => { - documentRoots.forEach((dr) => permissionStore.loadPermissions(dr.id)); + documentRoots.forEach((dr) => permissionStore.loadAllPermissions([dr.id])); setIsOpen(true); })} onClose={() => setIsOpen(false)} diff --git a/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx b/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx index 2c693a50b..726468d2a 100644 --- a/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx +++ b/src/components/documents/DynamicDocumentRoots/AddDynamicDocumentRoot/index.tsx @@ -20,7 +20,7 @@ const AddDynamicDocumentRoot = observer((props: Props) => { if (!dynamicDocumentRoot.root || !user?.hasElevatedAccess) { return; } - permissionStore.loadPermissions(dynamicDocumentRoot.documentRootId); + permissionStore.loadAllPermissions([dynamicDocumentRoot.documentRootId]); }, [dynamicDocumentRoot?.root, user?.hasElevatedAccess]); if (!user || !user.hasElevatedAccess) { return null; diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index 1dfe623e3..24361afd6 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -54,6 +54,15 @@ export abstract class TypeMeta { abstract get defaultData(): TypeDataMapping[T]; } +export class UnknownMeta extends TypeMeta<'_unknown_'> { + constructor(props: BaseMetaProps = {}) { + super('_unknown_', props, true); + } + get defaultData(): TypeDataMapping['_unknown_'] { + return { name: 'Unknown Document Type' }; + } +} + class DocumentRoot { readonly store: DocumentRootStore; readonly id: string; diff --git a/src/models/StudentGroup.ts b/src/models/StudentGroup.ts index 68ff236ac..99cc8f702 100644 --- a/src/models/StudentGroup.ts +++ b/src/models/StudentGroup.ts @@ -206,9 +206,11 @@ class StudentGroup { if (props) { this.store.root.documentStore.addPresentedDocumentToStore(this); // only admins will load permissions... - this.store.root.permissionStore.loadPermissions(props.document.documentRootId).catch((err) => { - console.error('Error loading permissions for presented document', err); - }); + this.store.root.permissionStore + .loadAllPermissions([props.document.documentRootId]) + .catch((err) => { + console.error('Error loading permissions for presented document', err); + }); } } diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index 7fd3a53ff..ffc0c5a9b 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -235,6 +235,10 @@ export const PageIndexPluginDefaultOptions: PageIndexPluginOptions = { { name: 'TrueFalseAnswer', docTypeExtractor: () => 'true_false_answer' + }, + { + name: 'Solution', + docTypeExtractor: () => 'solution' } ], persistedCodeType: (node: Code) => { diff --git a/src/stores/DocumentRootStore.ts b/src/stores/DocumentRootStore.ts index 1bdd56efc..cfc4110cf 100644 --- a/src/stores/DocumentRootStore.ts +++ b/src/stores/DocumentRootStore.ts @@ -1,7 +1,7 @@ import { action, computed, observable, runInAction } from 'mobx'; import { RootStore } from '@tdev-stores/rootStore'; import { computedFn } from 'mobx-utils'; -import DocumentRoot, { TypeMeta } from '@tdev-models/DocumentRoot'; +import DocumentRoot, { TypeMeta, UnknownMeta } from '@tdev-models/DocumentRoot'; import { Config, create as apiCreate, @@ -40,9 +40,9 @@ type LoadConfig = { /** * @option 'replace': the document root will be created and when already exists, * it will replace the existing one. - * @option 'addIfMissing': when the document root does not exist in the mobx store, it will + * @option 'addIfMissing': when the document root does not exist in the mobx store (or has an _unknown_ type), it will * be added. But no new document root will be created on the api. - * @option false: the document root will not be loaded. + * @option false: the document root will not be loaded * @default 'replace' */ documentRoot?: 'replace' | 'addIfMissing' | boolean; @@ -71,6 +71,7 @@ type BatchedMeta = { }; const DefaultMeta: TypeMeta[] = [ + new UnknownMeta({}), new CodeMeta({}), new MdxCommentMeta({}), new RestrictedMeta({}), @@ -313,7 +314,7 @@ export class DocumentRootStore extends iStore { if (config.load.documentRoot) { if (config.load.documentRoot === 'addIfMissing') { const current = this.find(data.id); - if (!current) { + if (!current || current.meta.type === '_unknown_') { this.addDocumentRoot(documentRoot); } } else { diff --git a/src/stores/PermissionStore.ts b/src/stores/PermissionStore.ts index e55265977..3e58998d4 100644 --- a/src/stores/PermissionStore.ts +++ b/src/stores/PermissionStore.ts @@ -13,19 +13,20 @@ import { updateUserPermission as updateUserPermissionApi, deleteUserPermission as deleteUserPermissionApi, deleteGroupPermission as deleteGroupPermissionApi, + documentRootPermissions as apiDocumentRootPermissions, permissionsFor } from '@tdev-api/permission'; -import DocumentRoot from '@tdev-models/DocumentRoot'; import User from '@tdev-models/User'; -import { Access } from '@tdev-api/document'; +import { Access, DocumentType } from '@tdev-api/document'; import StudentGroup from '@tdev-models/StudentGroup'; import { AccessLevels, NoneAccess } from '@tdev-models/helpers/accessPolicy'; +import DocumentRoot, { TypeMeta, UnknownMeta } from '@tdev-models/DocumentRoot'; class PermissionStore extends iStore<`update-${string}`> { readonly root: RootStore; userPermissions = observable.array([]); groupPermissions = observable.array([]); - @observable accessor permissionsLoadedForDocumentRootIds = new Set(); + permissionsLoadedForDocumentRootIds = observable.set(); constructor(root: RootStore) { super(); @@ -266,31 +267,61 @@ class PermissionStore extends iStore<`update-${string}`> { } @action - loadPermissions(documentRootId: string) { - if (!this.root.userStore.current?.hasElevatedAccess) { + loadAllPermissions(documentRootIds: string[], forceReload: boolean = false) { + const { current } = this.root.userStore; + if (!current?.hasElevatedAccess) { // API currently only allows elevated users to load permissions. return Promise.resolve(); } - if (this.permissionsLoadedForDocumentRootIds.has(documentRootId)) { + const idsToLoad = forceReload + ? documentRootIds + : documentRootIds.filter( + (id) => + !this.root.documentRootStore.find(id) || + !this.permissionsLoadedForDocumentRootIds.has(id) + ); + if (idsToLoad.length === 0) { return Promise.resolve(); } - return this.withAbortController(`load-permissions-${documentRootId}`, async (signal) => { - return permissionsFor(documentRootId, signal.signal).then( + return this.withAbortController(`load-all-permissions`, async (signal) => { + return apiDocumentRootPermissions(idsToLoad, signal.signal).then( action(({ data }) => { - const docRootId = data.id; - data.userPermissions.forEach((p) => { - this.addUserPermission(new UserPermission({ ...p, documentRootId: docRootId }, this)); - }); - data.groupPermissions.forEach((p) => { - this.addGroupPermission( - new GroupPermission({ ...p, documentRootId: docRootId }, this) + data.forEach((p) => { + const docRoot = new DocumentRoot( + { id: p.id, access: p.access, sharedAccess: p.sharedAccess }, + new UnknownMeta(), + this.root.documentRootStore, + true ); + const current = this.root.documentRootStore.find(p.id); + if (current && current.meta.type !== '_unknown_') { + current.setRootAccess(p.access, true); + current.setSharedAccess(p.sharedAccess, true); + } else { + this.root.documentRootStore.addDocumentRoot(docRoot); + } + + p.userPermissions.forEach((up) => { + this.addUserPermission(new UserPermission({ ...up, documentRootId: p.id }, this)); + }); + p.groupPermissions.forEach((gp) => { + this.addGroupPermission( + new GroupPermission({ ...gp, documentRootId: p.id }, this) + ); + }); + this.permissionsLoadedForDocumentRootIds.add(p.id); }); - this.permissionsLoadedForDocumentRootIds.add(documentRootId); }) ); }); } + + @action + cleanup() { + this.userPermissions.clear(); + this.groupPermissions.clear(); + this.permissionsLoadedForDocumentRootIds.clear(); + } } export default PermissionStore; diff --git a/src/stores/ViewStores/PermissionsControlView.ts b/src/stores/ViewStores/PermissionsControlView.ts new file mode 100644 index 000000000..a3c828410 --- /dev/null +++ b/src/stores/ViewStores/PermissionsControlView.ts @@ -0,0 +1,90 @@ +import { DocumentType } from '@tdev-api/document'; +import { RootStore } from '@tdev-stores/rootStore'; +import { action, computed, observable } from 'mobx'; + +export class PermissionsControlView { + readonly root: RootStore; + _typeFilter = observable.set(['solution']); + + constructor(root: RootStore) { + this.root = root; + } + + @computed + get pageIndex() { + return this.root.pageStore._pageIndex; + } + + @computed + get documentTypes() { + const types = new Set(); + for (const page of Object.values(this.root.pageStore._pageIndex)) { + types.add(page.type); + } + return Array.from(types).sort(); + } + + @computed + get typeFilter() { + if (this._typeFilter.size === 0) { + return new Set(this.documentTypes); + } + return this._typeFilter; + } + + @action + setTypeFilter(type: DocumentType, enabled: boolean) { + if (enabled) { + this._typeFilter.add(type); + } else { + this._typeFilter.delete(type); + } + } + + @computed + get relevantDocumentRootIds() { + const ids = new Set(); + for (const docs of Object.values(this.docsTree)) { + docs.forEach((doc) => ids.add(doc.id)); + } + return Array.from(ids); + } + + @action + toggleTypeFilter(type: DocumentType) { + this.setTypeFilter(type, !this._typeFilter.has(type)); + } + + @computed + get docsTree() { + const tree: Record = + {}; + this.pageIndex.forEach((page) => { + if (!this.typeFilter.has(page.type)) { + return; + } + if (!tree[page.path]) { + tree[page.path] = []; + } + tree[page.path].push({ + id: page.id, + type: page.type, + pageId: page.page_id, + position: page.position + }); + }); + + return tree; + } + + @computed + get typeColors() { + const n = this.documentTypes.length; + const colors: Map = new Map(); + this.documentTypes.forEach((type, idx) => { + const hue = (idx / n) * 360; + colors.set(type, `hsl(${hue}, 70%, 50%)`); + }); + return colors; + } +} diff --git a/src/stores/ViewStores/index.ts b/src/stores/ViewStores/index.ts index b726e4f46..90b25639b 100644 --- a/src/stores/ViewStores/index.ts +++ b/src/stores/ViewStores/index.ts @@ -1,6 +1,7 @@ import { ViewStoreType, ViewStore as ViewStores, ViewStoreTypeMapping } from '@tdev-api/document'; import { RootStore } from '@tdev-stores/rootStore'; -import { action, computed, observable } from 'mobx'; +import { action, computed, observable, observableRef } from 'mobx'; +import { PermissionsControlView } from './PermissionsControlView'; export interface ViewStoreProps { store: ViewStoreTypeMapping[T]; @@ -9,6 +10,7 @@ export interface ViewStoreProps { export default class ViewStore { readonly root: RootStore; stores = new Map(); + @observableRef accessor permissionControl: PermissionsControlView = null as any; @observable accessor fullscreenTargetId: string | null = null; @observable accessor isPageVisible: boolean = true; @observable accessor _presentationPanelState: null | 'open' | 'closed' = null; @@ -16,6 +18,7 @@ export default class ViewStore { constructor(store: RootStore) { this.root = store; + this.permissionControl = new PermissionsControlView(store); } @action diff --git a/src/stores/rootStore.ts b/src/stores/rootStore.ts index efc007823..9a0b5fb02 100644 --- a/src/stores/rootStore.ts +++ b/src/stores/rootStore.ts @@ -78,6 +78,7 @@ export class RootStore { this.userStore.cleanup(); this.socketStore.cleanup(); this.studentGroupStore.cleanup(); + this.permissionStore.cleanup(); } } From 1171211f71c3c859d20f1600aeebbf353bb4f316 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Thu, 6 Aug 2026 18:33:41 +0200 Subject: [PATCH 2/7] add path filter --- .../DocumentTypeSelector.tsx | 89 +++++++++++++++++++ .../Admin/PermissionsControl/index.tsx | 25 ++---- .../PermissionsControl/styles.module.scss | 5 ++ .../ViewStores/PermissionsControlView.ts | 45 +++++++--- 4 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 src/components/Admin/PermissionsControl/DocumentTypeSelector.tsx diff --git a/src/components/Admin/PermissionsControl/DocumentTypeSelector.tsx b/src/components/Admin/PermissionsControl/DocumentTypeSelector.tsx new file mode 100644 index 000000000..c55d41274 --- /dev/null +++ b/src/components/Admin/PermissionsControl/DocumentTypeSelector.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import Select, { StylesConfig } from 'react-select'; +import { DocumentType, TypeModelMapping } from '@tdev-api/document'; +import Button from '@tdev-components/shared/Button'; + +interface Props {} +const colourStyles: StylesConfig<{ value: DocumentType; hslDeg: number; label: DocumentType }, true> = { + control: (styles) => ({ ...styles, backgroundColor: 'white' }), + option: (styles, { data, isFocused, isSelected }) => { + return { + ...styles, + backgroundColor: isFocused ? `hsl(${data.hslDeg}, 70%, 70%)` : undefined, + color: `hsl(${data.hslDeg}, 100%, 25%)` + }; + }, + multiValue: (styles, { data }) => { + return { + ...styles, + backgroundColor: `hsl(${data.hslDeg}, 90%, 70%)` + }; + }, + multiValueLabel: (styles, { data }) => ({ + ...styles, + color: `hsl(${data.hslDeg}, 100%, 30%)` + }), + multiValueRemove: (styles, { data }) => ({ + ...styles, + color: `hsl(${data.hslDeg}, 100%, 30%)`, + ':hover': { + backgroundColor: `hsl(${data.hslDeg}, 100%, 50%)`, + color: 'white' + } + }), + menuPortal: (base) => ({ ...base, zIndex: 'var(--ifm-z-index-overlay)' }), + container: (base) => ({ ...base, minWidth: '15em' }) +}; + +const DocumentTypeSelector = observer((props: Props) => { + const viewStore = useStore('viewStore'); + const view = viewStore.permissionControl; + + return ( +
+