From ed4def78226d4df498dc7b37c91e0d180361dbcc Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 09:43:30 -0500 Subject: [PATCH 01/22] add drawer management feature flag --- .env.example | 1 + src/config.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 94c80014..35d85286 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,4 @@ VITE_AUTOLOAD_MAX_SEARCH_RESULTS=false # FEATURE FLAGS VITE_FEATURE_ADMIN_PERMISSIONS=false +VITE_FEATURE_DRAWER_MANAGEMENT=false diff --git a/src/config.ts b/src/config.ts index 029d4393..a8fc6133 100644 --- a/src/config.ts +++ b/src/config.ts @@ -62,6 +62,8 @@ const defaultConfig: AppConfig = { features: { adminPermissions: import.meta.env.VITE_FEATURE_ADMIN_PERMISSIONS?.toLowerCase() === "true", + drawerManagement: + import.meta.env.VITE_FEATURE_DRAWER_MANAGEMENT?.toLowerCase() === "true", }, }; From 800c878709c3d96de329d0fd6018a28bd52ec844 Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 09:44:12 -0500 Subject: [PATCH 02/22] drawer management page --- .../DrawerManagementPage.vue | 63 +++++++++++++++++++ src/router.ts | 19 ++++++ 2 files changed, 82 insertions(+) create mode 100644 src/pages/DrawerManagementPage/DrawerManagementPage.vue diff --git a/src/pages/DrawerManagementPage/DrawerManagementPage.vue b/src/pages/DrawerManagementPage/DrawerManagementPage.vue new file mode 100644 index 00000000..0899e4fa --- /dev/null +++ b/src/pages/DrawerManagementPage/DrawerManagementPage.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/router.ts b/src/router.ts index 7abcb7d8..b11506d6 100644 --- a/src/router.ts +++ b/src/router.ts @@ -87,6 +87,24 @@ const createAdminPermissionsRoutes = (): RouteRecordRaw[] => { ]; }; +// Flag-off URLs 404 via the catchall, same reasoning as +// createAdminPermissionsRoutes above. +const createDrawerManagementRoutes = (): RouteRecordRaw[] => { + if (!config.features.drawerManagement) return []; + return [ + { + name: "drawerManagement", + path: "/drawers/manage", + component: () => + import("@/pages/DrawerManagementPage/DrawerManagementPage.vue"), + meta: { + requiresAuth: true, + canAccess: (user: User) => user.canManageDrawers, + }, + }, + ]; +}; + const router = createRouter({ history: createWebHistory(config.instance.base.path), scrollBehavior(to, from, savedPosition) { @@ -354,6 +372,7 @@ const router = createRouter({ props: { templateId: null }, }, ...createAdminPermissionsRoutes(), + ...createDrawerManagementRoutes(), { name: "mapClusterTest", path: "/tests/map", From a19b9c2ff2958b3eea105b49d5e17840cc80845c Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 09:44:45 -0500 Subject: [PATCH 03/22] update app menu to contain drawer management page --- src/components/AppMenu/AppMenu.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/AppMenu/AppMenu.vue b/src/components/AppMenu/AppMenu.vue index cc26f7da..0f53a8dc 100644 --- a/src/components/AppMenu/AppMenu.vue +++ b/src/components/AppMenu/AppMenu.vue @@ -21,6 +21,15 @@ Drawers + + Manage Drawers + + Date: Fri, 10 Jul 2026 11:50:47 -0500 Subject: [PATCH 04/22] consolidate makeQueryKeys to avoid repeating explanation --- src/helpers/makeQueryKeysFor.ts | 20 +++++++++++++++++++ .../AdminPermissionsPage/groupQueries.ts | 16 +++++++-------- src/pages/AdminPermissionsPage/ruleQueries.ts | 5 +++-- 3 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 src/helpers/makeQueryKeysFor.ts diff --git a/src/helpers/makeQueryKeysFor.ts b/src/helpers/makeQueryKeysFor.ts new file mode 100644 index 00000000..143c2e52 --- /dev/null +++ b/src/helpers/makeQueryKeysFor.ts @@ -0,0 +1,20 @@ +/** + * Query keys for one resource, split into a "list" branch and an "item" + * branch. Invalidation matches keys by prefix, so the kind comes right + * after the resource: the list can refresh without refetching every + * item, and [resource, "item"] targets all items but not the list. + * See https://tkdodo.eu/blog/effective-react-query-keys + * + * @example + * ```ts + * const groupKeys = makeQueryKeysFor("drawerGroups"); + * queryClient.invalidateQueries({ queryKey: groupKeys.list() }); + * queryClient.removeQueries({ queryKey: groupKeys.item(deletedId) }); + * ``` + */ +export function makeQueryKeysFor(resource: Resource) { + return { + list: () => [resource, "list"] as const, + item: (id: number) => [resource, "item", id] as const, + }; +} diff --git a/src/pages/AdminPermissionsPage/groupQueries.ts b/src/pages/AdminPermissionsPage/groupQueries.ts index 563c9f84..74b883f2 100644 --- a/src/pages/AdminPermissionsPage/groupQueries.ts +++ b/src/pages/AdminPermissionsPage/groupQueries.ts @@ -3,20 +3,18 @@ import { computed, toValue, type MaybeRefOrGetter } from "vue"; import * as fetchers from "@/api/fetchers"; import type { AddGroupMemberInput } from "@/api/fetchers"; import { useToastStore } from "@/stores/toastStore"; +import { makeQueryKeysFor } from "@/helpers/makeQueryKeysFor"; import type { PermissionsGroup, UpdateGroupPayload } from "@/types"; -// Invalidation matches keys by prefix. The kind ("list" or "item") -// comes right after the resource so list data and item data are -// separate branches: the list can be refreshed without refetching -// every group, and ["groups", "item"] targets all items but not the -// list. See https://tkdodo.eu/blog/effective-react-query-keys +const groupKeys = makeQueryKeysFor("groups"); + export const makeQueryKeyFor = { - groupsList: () => ["groups", "list"] as const, - groupDetails: (groupId: number) => ["groups", "item", groupId] as const, + groupsList: groupKeys.list, + groupDetails: groupKeys.item, groupEntries: (groupId: number) => - [...makeQueryKeyFor.groupDetails(groupId), "entries"] as const, + [...groupKeys.item(groupId), "entries"] as const, groupMembers: (groupId: number) => - [...makeQueryKeyFor.groupDetails(groupId), "members"] as const, + [...groupKeys.item(groupId), "members"] as const, groupTypes: () => ["groupTypes"] as const, }; diff --git a/src/pages/AdminPermissionsPage/ruleQueries.ts b/src/pages/AdminPermissionsPage/ruleQueries.ts index d06c904c..888b7c1f 100644 --- a/src/pages/AdminPermissionsPage/ruleQueries.ts +++ b/src/pages/AdminPermissionsPage/ruleQueries.ts @@ -1,12 +1,13 @@ import { queryOptions, useMutation, useQueryClient } from "@tanstack/vue-query"; import * as fetchers from "@/api/fetchers"; import { useToastStore } from "@/stores/toastStore"; +import { makeQueryKeysFor } from "@/helpers/makeQueryKeysFor"; import type { CollectionGrant, InstanceGrant } from "@/types"; // A "rule" in the UI is a "grant" in the backend: instance or collection scope. export const makeQueryKeyFor = { - instanceGrantsList: () => ["instanceGrants", "list"] as const, - collectionGrantsList: () => ["collectionGrants", "list"] as const, + instanceGrantsList: makeQueryKeysFor("instanceGrants").list, + collectionGrantsList: makeQueryKeysFor("collectionGrants").list, permissionLevels: () => ["permissionLevels"] as const, }; From e25d3cc2c3f4894ee34316e6670d6b1747d023e3 Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 12:06:12 -0500 Subject: [PATCH 05/22] drawer management --- src/api/fetchers.ts | 64 ++++ .../DrawerManagementPage.vue | 4 +- .../DrawersTabContent.vue | 148 ++++++++ .../DrawersTableColumns.tsx | 24 ++ .../DrawerManagementPage/GroupFormModal.vue | 181 ++++++++++ .../DrawerManagementPage/GroupsTabContent.vue | 318 ++++++++++++++++++ .../GroupsTableColumns.tsx | 84 +++++ .../DrawerManagementPage/RulesTabContent.vue | 6 + .../drawerGroupQueries.ts | 98 ++++++ src/router.ts | 2 - src/types/PermissionsTypes.ts | 51 +-- src/types/index.ts | 1 + 12 files changed, 942 insertions(+), 39 deletions(-) create mode 100644 src/pages/DrawerManagementPage/DrawersTabContent.vue create mode 100644 src/pages/DrawerManagementPage/DrawersTableColumns.tsx create mode 100644 src/pages/DrawerManagementPage/GroupFormModal.vue create mode 100644 src/pages/DrawerManagementPage/GroupsTabContent.vue create mode 100644 src/pages/DrawerManagementPage/GroupsTableColumns.tsx create mode 100644 src/pages/DrawerManagementPage/RulesTabContent.vue create mode 100644 src/pages/DrawerManagementPage/drawerGroupQueries.ts diff --git a/src/api/fetchers.ts b/src/api/fetchers.ts index 7a774f54..6af7109d 100644 --- a/src/api/fetchers.ts +++ b/src/api/fetchers.ts @@ -56,6 +56,7 @@ import { type PermissionLevel, type InstanceGrant, type CollectionGrant, + type ManageableDrawer, } from "@/types"; import { FileMetaData } from "@/types/FileMetaDataTypes"; import { FileDownloadResponse } from "@/types/FileDownloadTypes"; @@ -1323,6 +1324,7 @@ export async function createGroup( return res.data.group; } +// Changing the type clears the group's existing members server-side. export async function updateGroup( groupId: number, payload: UpdateGroupPayload @@ -1443,3 +1445,65 @@ export async function removeGroupEntry( `${BASE_URL}/adminPermissions/groups/${groupId}/entries/${entryId}` ); } +export async function fetchDrawerGroupTypes(): Promise { + const res = await axios.get<{ groupTypes: GroupTypeDetails[] }>( + `${BASE_URL}/drawerPermissions/groupTypes` + ); + + return res.data.groupTypes; +} + +export async function fetchManageableDrawers(): Promise { + const res = await axios.get<{ drawers: ManageableDrawer[] }>( + `${BASE_URL}/drawerPermissions/drawers` + ); + + return res.data.drawers; +} + +export async function fetchDrawerGroups(): Promise { + const res = await axios.get<{ groups: PermissionsGroup[] }>( + `${BASE_URL}/drawerPermissions/groups` + ); + + return res.data.groups; +} + +export async function createDrawerGroup( + payload: CreateGroupPayload +): Promise { + const params = new URLSearchParams(); + params.append("label", payload.label); + params.append("type", payload.type); + for (const value of payload.values) { + params.append("values[]", value); + } + + const res = await axios.post<{ group: PermissionsGroup }>( + `${BASE_URL}/drawerPermissions/groups`, + params + ); + + return res.data.group; +} + +// Changing the type clears the group's existing members server-side. +export async function updateDrawerGroup( + groupId: number, + payload: UpdateGroupPayload +): Promise { + const params = new URLSearchParams(); + params.append("label", payload.label); + params.append("type", payload.type); + + const res = await axios.put<{ group: PermissionsGroup }>( + `${BASE_URL}/drawerPermissions/groups/${groupId}`, + params + ); + + return res.data.group; +} + +export async function deleteDrawerGroup(groupId: number): Promise { + await axios.delete(`${BASE_URL}/drawerPermissions/groups/${groupId}`); +} diff --git a/src/pages/DrawerManagementPage/DrawerManagementPage.vue b/src/pages/DrawerManagementPage/DrawerManagementPage.vue index 0899e4fa..5a1ce000 100644 --- a/src/pages/DrawerManagementPage/DrawerManagementPage.vue +++ b/src/pages/DrawerManagementPage/DrawerManagementPage.vue @@ -40,7 +40,7 @@ type ValidTab = (typeof VALID_TABS)[number]; const route = useRoute(); const router = useRouter(); -// helper for TS since route.query.tab could be array +// route.query.tab can be an array, so narrow to a single valid tab const isValidTab = (x: unknown): x is ValidTab => VALID_TABS.some((tab) => tab === x); @@ -54,7 +54,7 @@ const activeTabId = computed({ router.replace({ query: { ...route.query, - // only include tabId in query if it's not the default (first tab) + // keep the default tab's URL clean by omitting the param tab: tabId === VALID_TABS[0] ? undefined : tabId, }, }); diff --git a/src/pages/DrawerManagementPage/DrawersTabContent.vue b/src/pages/DrawerManagementPage/DrawersTabContent.vue new file mode 100644 index 00000000..ddb4d2ad --- /dev/null +++ b/src/pages/DrawerManagementPage/DrawersTabContent.vue @@ -0,0 +1,148 @@ + + diff --git a/src/pages/DrawerManagementPage/DrawersTableColumns.tsx b/src/pages/DrawerManagementPage/DrawersTableColumns.tsx new file mode 100644 index 00000000..4a7df168 --- /dev/null +++ b/src/pages/DrawerManagementPage/DrawersTableColumns.tsx @@ -0,0 +1,24 @@ +import { createColumnHelper } from "@tanstack/vue-table"; +import type { ManageableDrawer } from "@/types"; +import Link from "@/components/Link/Link.vue"; +import { ColHeader } from "../AdminPermissionsPage/ColHeader"; + +const columnHelper = createColumnHelper(); + +function toDrawerTitle(drawer: ManageableDrawer): string { + return drawer.title || `Drawer ${drawer.id}`; +} + +export const drawerColumns = [ + columnHelper.accessor(toDrawerTitle, { + id: "title", + header: () => , + cell: (ctx) => ( + + {ctx.getValue()} + + ), + }), +]; diff --git a/src/pages/DrawerManagementPage/GroupFormModal.vue b/src/pages/DrawerManagementPage/GroupFormModal.vue new file mode 100644 index 00000000..9e7152ee --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupFormModal.vue @@ -0,0 +1,181 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupsTabContent.vue b/src/pages/DrawerManagementPage/GroupsTabContent.vue new file mode 100644 index 00000000..937416c0 --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupsTabContent.vue @@ -0,0 +1,318 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupsTableColumns.tsx b/src/pages/DrawerManagementPage/GroupsTableColumns.tsx new file mode 100644 index 00000000..2b6561e8 --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupsTableColumns.tsx @@ -0,0 +1,84 @@ +import { createColumnHelper } from "@tanstack/vue-table"; +import { PencilIcon, TrashIcon } from "lucide-vue-next"; +import type { PermissionsGroup } from "@/types"; +import { cn } from "@/lib/utils"; +import IconButton from "@/components/IconButton/IconButton.vue"; +import Chip from "@/components/Chip/Chip.vue"; +import { ColHeader } from "../AdminPermissionsPage/ColHeader"; + +export interface DrawerGroupRow { + group: PermissionsGroup; + name: string; + typeLabel: string; + entriesCount: number; + isGlobal: boolean; // e.g. `All`, `Authed`, `Users`, ... +} + +const columnHelper = createColumnHelper(); + +export const createDrawerGroupColumns = ( + onEdit: (group: PermissionsGroup) => void, + onDelete: (group: PermissionsGroup) => void +) => [ + columnHelper.accessor("name", { + id: "name", + header: () => , + meta: { widthClass: "w-[42%]" }, + cell: (ctx) => ( +
{ctx.getValue()}
+ ), + }), + columnHelper.accessor("typeLabel", { + id: "type", + header: () => , + meta: { widthClass: "w-[42%]" }, + cell: (ctx) => ( +
{ctx.getValue()}
+ ), + }), + columnHelper.accessor("entriesCount", { + id: "membersEntries", + header: () => , + enableColumnFilter: false, + enableGlobalFilter: false, + meta: { widthClass: "w-[16%]" }, + cell: (ctx) => + ctx.row.original.isGlobal ? ( +
+ ) : ( + 0 + ? "bg-secondary-container" + : "bg-transparent text-on-surface-variant" + )}> + {ctx.getValue()} + + ), + }), + { + id: "actions", + header: () => null, + enableSorting: false, + meta: { widthClass: "w-20" }, + cell: ({ row }: { row: { original: DrawerGroupRow } }) => ( +
+ onEdit(row.original.group)} + title="Edit Group" + data-group-edit={row.original.group.id} + showTooltip={false}> + + + onDelete(row.original.group)} + title="Delete Group" + showTooltip={false} + class="enabled:text-error enabled:hover:bg-error-container enabled:hover:text-on-error-container"> + + +
+ ), + }, +]; diff --git a/src/pages/DrawerManagementPage/RulesTabContent.vue b/src/pages/DrawerManagementPage/RulesTabContent.vue new file mode 100644 index 00000000..ceff4823 --- /dev/null +++ b/src/pages/DrawerManagementPage/RulesTabContent.vue @@ -0,0 +1,6 @@ + diff --git a/src/pages/DrawerManagementPage/drawerGroupQueries.ts b/src/pages/DrawerManagementPage/drawerGroupQueries.ts new file mode 100644 index 00000000..e644f0e6 --- /dev/null +++ b/src/pages/DrawerManagementPage/drawerGroupQueries.ts @@ -0,0 +1,98 @@ +import { queryOptions, useMutation, useQueryClient } from "@tanstack/vue-query"; +import * as fetchers from "@/api/fetchers"; +import { useToastStore } from "@/stores/toastStore"; +import { makeQueryKeysFor } from "@/helpers/makeQueryKeysFor"; +import type { UpdateGroupPayload, PermissionsGroup } from "@/types"; + +// "drawerGroups", not "groups", which the instance page already uses +const drawerGroupKeys = makeQueryKeysFor("drawerGroups"); + +export const queryKeys = { + drawerGroupsList: drawerGroupKeys.list, + drawerGroupItem: drawerGroupKeys.item, + drawerGroupTypes: () => ["drawerGroupTypes"] as const, + manageableDrawers: () => ["manageableDrawers"] as const, +}; + +export function drawerGroupsQuery() { + return queryOptions({ + queryKey: queryKeys.drawerGroupsList(), + queryFn: fetchers.fetchDrawerGroups, + }); +} + +export function drawerGroupTypesQuery() { + return queryOptions({ + queryKey: queryKeys.drawerGroupTypes(), + queryFn: fetchers.fetchDrawerGroupTypes, + // Types never change over a page's life, so fetch once and reuse. + staleTime: Infinity, + }); +} + +export function manageableDrawersQuery() { + return queryOptions({ + queryKey: queryKeys.manageableDrawers(), + queryFn: fetchers.fetchManageableDrawers, + }); +} + +export function useCreateDrawerGroupMutation() { + const queryClient = useQueryClient(); + const toastStore = useToastStore(); + + return useMutation({ + mutationFn: fetchers.createDrawerGroup, + onSuccess: (group) => toastStore.success(`Group "${group.label}" created.`), + onError: (error) => + toastStore.error(error.message, { title: "Could not create group" }), + // return the promise so isPending stays true while the refetch is + // in flight + onSettled: () => + queryClient.invalidateQueries({ + queryKey: queryKeys.drawerGroupsList(), + }), + }); +} + +export function useUpdateDrawerGroupMutation() { + const queryClient = useQueryClient(); + const toastStore = useToastStore(); + + return useMutation({ + mutationFn: (vars: { id: number; payload: UpdateGroupPayload }) => + fetchers.updateDrawerGroup(vars.id, vars.payload), + onSuccess: (group) => toastStore.success(`Group "${group.label}" updated.`), + onError: (error) => + toastStore.error(error.message, { title: "Could not update group" }), + // The list shows label and type, so it goes stale along with the item. + onSettled: (_group, _error, vars) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.drawerGroupItem(vars.id), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.drawerGroupsList(), + }), + ]), + }); +} + +// Toasts for delete live at the call site, next to the confirm dialog. +export function useDeleteDrawerGroupMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: PermissionsGroup["id"]) => fetchers.deleteDrawerGroup(id), + // The group no longer exists, so drop its item subtree instead of + // invalidating it (a refetch would 404). + onSettled: (_data, _error, id) => { + queryClient.removeQueries({ + queryKey: queryKeys.drawerGroupItem(id), + }); + return queryClient.invalidateQueries({ + queryKey: queryKeys.drawerGroupsList(), + }); + }, + }); +} diff --git a/src/router.ts b/src/router.ts index b11506d6..7a872897 100644 --- a/src/router.ts +++ b/src/router.ts @@ -87,8 +87,6 @@ const createAdminPermissionsRoutes = (): RouteRecordRaw[] => { ]; }; -// Flag-off URLs 404 via the catchall, same reasoning as -// createAdminPermissionsRoutes above. const createDrawerManagementRoutes = (): RouteRecordRaw[] => { if (!config.features.drawerManagement) return []; return [ diff --git a/src/types/PermissionsTypes.ts b/src/types/PermissionsTypes.ts index 525ba3f4..f8d53250 100644 --- a/src/types/PermissionsTypes.ts +++ b/src/types/PermissionsTypes.ts @@ -1,3 +1,5 @@ +import { ISODateTime } from "."; + export const GROUP_TYPES = { ALL: "All", AUTHED: "Authed", @@ -9,24 +11,20 @@ type GroupTypeKeys = keyof typeof GROUP_TYPES; export type GroupTypeValues = (typeof GROUP_TYPES)[GroupTypeKeys]; -// A suggested entry value for an auth-helper group type, from the signed-in -// admin's own session data (e.g. courses they teach). Often legitimately -// empty: local admins and some types (JobCode) have no hints. export interface EntryHint { value: string; label: string; } +// GET /adminPermissions/groupTypes and /drawerPermissions/groupTypes export interface GroupTypeDetails { type: GroupTypeValues; label: string; description: string; entryHints: EntryHint[]; + adminOnly: boolean; // types only usable by admins (`All`, `Authed`, ...) } -// One raw match value of a value-based group: an auth attribute string -// the group matches on. User groups store resolved user ids here and -// manage them through the members endpoints instead. export interface PermissionsGroupEntry { id: number; value: string; @@ -39,7 +37,7 @@ export interface PermissionsGroup { entries_count: number; } -// Auth-helper types are defined per campus by the backend's AuthHelper +// Auth-helper types are defined by the backend's AuthHelper // classes, so the UI can only recognize them as "not one of the built-in // GROUP_TYPES". The backend rejects entry writes on other types anyway. export function isAuthHelperGroupType(group: PermissionsGroup): boolean { @@ -47,17 +45,13 @@ export function isAuthHelperGroupType(group: PermissionsGroup): boolean { return !builtInTypes.includes(group.type); } -// A group with something to manage inside: User groups manage members, -// auth-helper groups manage match values. Global types match everyone -// and hold nothing. +// A group with something to manage inside: members, entries export function isManageableGroup(group: PermissionsGroup): boolean { return group.type === GROUP_TYPES.USER || isAuthHelperGroupType(group); } // The numeric permission tiers, mirroring the API's PERM_* constants in -// application/config/constants.php (same names, so they grep across both -// repos). The API also defines aliases DERIVATIVES_GROUP_1 (20) and -// ORIGINALSWITHOUTDERIVATIVES (25), omitted here. +// application/config/constants.php. export const PERM = { NOPERM: 0, SEARCH: 10, @@ -69,27 +63,19 @@ export const PERM = { ADMIN: 60, } as const; -// A permission tier from GET /adminPermissions/permissionLevels. `level` is -// the numeric strength (a PERM value) that access checks compare; grants -// reference tiers by `id`. export interface PermissionLevel { id: number; - level: number; + level: number; // 0, 10, 20, ... see PERM name: string; label: string; } -// A stored grant row from GET /adminPermissions/instanceGrants: the group -// holds the level on every collection in the instance. The id fields are -// null on orphaned legacy rows whose group or level was deleted. export interface InstanceGrant { id: number; groupId: number | null; permissionLevelId: number | null; } -// A stored grant row from GET /adminPermissions/collectionGrants: the group -// holds the level on one collection and its descendants. export interface CollectionGrant { id: number; collectionId: number | null; @@ -97,40 +83,35 @@ export interface CollectionGrant { permissionLevelId: number | null; } -// A user-autocomplete suggestion. `localUserId` is the local user id, or -// null for someone the directory knows but who has no local row yet. export interface UserAutocompleteMatch { name: string; email: string; + // null for someone the directory knows but who has no local row yet. localUserId: number | null; username: string; } -// A member of a User group, resolved to display data so the UI can show who -// belongs (the group list itself only carries ids). export interface GroupMember { userId: number; name: string; email: string; username: string; - // "Local" or "Remote" — a Remote member may be a stub that has never - // signed in. - userType: string; - // ISO 8601, or null. When the user account was created. - createdAt: string | null; + userType: "Local" | "Remote"; + createdAt: ISODateTime | null; } -// `values` holds "User"-group members. Always sent, even when empty for a -// group with no members yet. The backend sets group_value itself. export interface CreateGroupPayload { type: GroupTypeValues; label: string; values: string[]; } -// Edit a group's label and type. Members are not edited here. Changing the -// type clears existing members server-side. export interface UpdateGroupPayload { type: GroupTypeValues; label: string; } + +export interface ManageableDrawer { + id: number; + title: string | null; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4b5ca6b2..41422dd8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,6 +58,7 @@ export interface AppConfig { isUsingMockServer?: boolean; features: { adminPermissions: boolean; + drawerManagement: boolean; }; } From 12c593d40e29fbf3500fca699ea0cf6c9ffce58c Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 13:31:52 -0500 Subject: [PATCH 06/22] remove dead values stuff --- src/api/fetchers.ts | 6 ------ src/pages/AdminPermissionsPage/GroupFormModal.vue | 2 +- src/pages/DrawerManagementPage/GroupFormModal.vue | 3 +-- src/types/PermissionsTypes.ts | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/api/fetchers.ts b/src/api/fetchers.ts index 6af7109d..fad80a97 100644 --- a/src/api/fetchers.ts +++ b/src/api/fetchers.ts @@ -1312,9 +1312,6 @@ export async function createGroup( const params = new URLSearchParams(); params.append("label", payload.label); params.append("type", payload.type); - for (const value of payload.values) { - params.append("values[]", value); - } const res = await axios.post<{ group: PermissionsGroup }>( `${BASE_URL}/adminPermissions/groups`, @@ -1475,9 +1472,6 @@ export async function createDrawerGroup( const params = new URLSearchParams(); params.append("label", payload.label); params.append("type", payload.type); - for (const value of payload.values) { - params.append("values[]", value); - } const res = await axios.post<{ group: PermissionsGroup }>( `${BASE_URL}/drawerPermissions/groups`, diff --git a/src/pages/AdminPermissionsPage/GroupFormModal.vue b/src/pages/AdminPermissionsPage/GroupFormModal.vue index e31f3f47..fa666b3d 100644 --- a/src/pages/AdminPermissionsPage/GroupFormModal.vue +++ b/src/pages/AdminPermissionsPage/GroupFormModal.vue @@ -147,7 +147,7 @@ function handleSubmit() { ); } else { createMutation.mutate( - { type, label, values: [] }, + { type, label }, { // createMutation has no optimistic update, so wait for the list // invalidation to settle before closing, otherwise tryFocus in diff --git a/src/pages/DrawerManagementPage/GroupFormModal.vue b/src/pages/DrawerManagementPage/GroupFormModal.vue index 9e7152ee..78afdbaa 100644 --- a/src/pages/DrawerManagementPage/GroupFormModal.vue +++ b/src/pages/DrawerManagementPage/GroupFormModal.vue @@ -164,8 +164,7 @@ function handleSubmit() { ); } else { createMutation.mutate( - // the drawer form has no member picker, so groups start empty - { type, label, values: [] }, + { type, label }, { // close only after the list refetch lands, otherwise tryFocus // in the parent can't find the new group's row yet diff --git a/src/types/PermissionsTypes.ts b/src/types/PermissionsTypes.ts index f8d53250..812161e5 100644 --- a/src/types/PermissionsTypes.ts +++ b/src/types/PermissionsTypes.ts @@ -103,7 +103,6 @@ export interface GroupMember { export interface CreateGroupPayload { type: GroupTypeValues; label: string; - values: string[]; } export interface UpdateGroupPayload { From d1a5583e446a11ee2763a4ce32576fb264029a5d Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 13:37:25 -0500 Subject: [PATCH 07/22] enabled DRAWER_GROUPS flag on deploy --- bin/deploy-dev.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/deploy-dev.sh b/bin/deploy-dev.sh index 5dd752d5..2db7d7e7 100755 --- a/bin/deploy-dev.sh +++ b/bin/deploy-dev.sh @@ -30,6 +30,7 @@ if [ ! -f .env ]; then echo "→ enabling admin permissions feature flag in .env..." echo "VITE_FEATURE_ADMIN_PERMISSIONS=true" >> .env + echo "VITE_FEATURE_DRAWER_GROUPS=true" >> .env fi echo "→ Installing dependencies..." From 7328f52bafabe06c07f0b9a318b85c3765c6e3f2 Mon Sep 17 00:00:00 2001 From: James Johnson Date: Fri, 10 Jul 2026 14:13:27 -0500 Subject: [PATCH 08/22] remove duplicate group members button --- src/pages/AdminPermissionsPage/GroupMembersTable.vue | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/AdminPermissionsPage/GroupMembersTable.vue b/src/pages/AdminPermissionsPage/GroupMembersTable.vue index 89e5ae3d..f6ba01c7 100644 --- a/src/pages/AdminPermissionsPage/GroupMembersTable.vue +++ b/src/pages/AdminPermissionsPage/GroupMembersTable.vue @@ -57,9 +57,6 @@ No members yet. - - + + + + + + diff --git a/src/pages/DrawerManagementPage/GroupEntriesTableRow.vue b/src/pages/DrawerManagementPage/GroupEntriesTableRow.vue new file mode 100644 index 00000000..64c6c790 --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupEntriesTableRow.vue @@ -0,0 +1,143 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupEntryValueInput.vue b/src/pages/DrawerManagementPage/GroupEntryValueInput.vue new file mode 100644 index 00000000..21cd6c5a --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupEntryValueInput.vue @@ -0,0 +1,105 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupsTableColumns.tsx b/src/pages/DrawerManagementPage/GroupsTableColumns.tsx index 3dcef457..4684b71b 100644 --- a/src/pages/DrawerManagementPage/GroupsTableColumns.tsx +++ b/src/pages/DrawerManagementPage/GroupsTableColumns.tsx @@ -4,12 +4,14 @@ import type { PermissionsGroup } from "@/types"; import { cn } from "@/lib/utils"; import IconButton from "@/components/IconButton/IconButton.vue"; import Chip from "@/components/Chip/Chip.vue"; +import ChevronRightIcon from "@/icons/ChevronRightIcon.vue"; import { ColHeader } from "../AdminPermissionsPage/ColHeader"; export interface DrawerGroupRow { group: PermissionsGroup; name: string; typeLabel: string; + description: string; entriesCount: number; isGlobal: boolean; // e.g. `All`, `Authed`, `Users`, ... isPersonal: boolean; @@ -21,10 +23,32 @@ export const createDrawerGroupColumns = ( onEdit: (group: PermissionsGroup) => void, onDelete: (group: PermissionsGroup) => void ) => [ + columnHelper.display({ + id: "expander", + header: () => null, + enableSorting: false, + meta: { widthClass: "w-10" }, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }), columnHelper.accessor("name", { id: "name", header: () => , - meta: { widthClass: "w-[42%]" }, + meta: { widthClass: "w-[38%]" }, cell: (ctx) => (
{ctx.getValue()}
), @@ -32,7 +56,7 @@ export const createDrawerGroupColumns = ( columnHelper.accessor("typeLabel", { id: "type", header: () => , - meta: { widthClass: "w-[42%]" }, + meta: { widthClass: "w-[38%]" }, cell: (ctx) => (
{ctx.getValue()}
), @@ -42,7 +66,7 @@ export const createDrawerGroupColumns = ( header: () => , enableColumnFilter: false, enableGlobalFilter: false, - meta: { widthClass: "w-[16%]" }, + meta: { widthClass: "w-[14%]" }, cell: (ctx) => ctx.row.original.isGlobal ? (
From 4f1ad9c7ca06ba05619e347fb0c82f040b589b5e Mon Sep 17 00:00:00 2001 From: James Johnson Date: Sat, 11 Jul 2026 11:50:12 -0500 Subject: [PATCH 18/22] group member management --- .../AddGroupMemberRow.vue | 262 ++++++++++++++++++ .../GroupMemberManager.vue | 106 +++++++ .../DrawerManagementPage/GroupsTabContent.vue | 124 +++++++-- 3 files changed, 469 insertions(+), 23 deletions(-) create mode 100644 src/pages/DrawerManagementPage/AddGroupMemberRow.vue create mode 100644 src/pages/DrawerManagementPage/GroupMemberManager.vue diff --git a/src/pages/DrawerManagementPage/AddGroupMemberRow.vue b/src/pages/DrawerManagementPage/AddGroupMemberRow.vue new file mode 100644 index 00000000..7b89ed93 --- /dev/null +++ b/src/pages/DrawerManagementPage/AddGroupMemberRow.vue @@ -0,0 +1,262 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupMemberManager.vue b/src/pages/DrawerManagementPage/GroupMemberManager.vue new file mode 100644 index 00000000..a5ab82d7 --- /dev/null +++ b/src/pages/DrawerManagementPage/GroupMemberManager.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/pages/DrawerManagementPage/GroupsTabContent.vue b/src/pages/DrawerManagementPage/GroupsTabContent.vue index f19e578c..c701eafc 100644 --- a/src/pages/DrawerManagementPage/GroupsTabContent.vue +++ b/src/pages/DrawerManagementPage/GroupsTabContent.vue @@ -68,23 +68,51 @@