admin/accounts: show as tree
Massimo Melina committed
Feb 24, 2025 at 00:50 UTC
ec36c5c0da38dbb1f3c4fee9ab24edca013b7c7a
3 files changed
+42
-30
admin/src/AccountsPage.ts
+39
-29
@@ -1,24 +1,26 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, useState, useEffect, Fragment, useMemo } from "react"
3
+import { createElement as h, useState, useEffect, Fragment, useMemo, ReactNode } from "react"
4
import { apiCall, useApiEx } from './api'
5
import { Alert, Box, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6
-import { Close, Delete, DoNotDisturb, Group, MilitaryTech, Person, PersonAdd, Schedule } from '@mui/icons-material'
6
+import {
7
+ AccountTree, ChevronRight, Close, Delete, DoNotDisturb, ExpandMore, Group, MilitaryTech, Person, PersonAdd, Schedule
8
+} from '@mui/icons-material'
9
import { newDialog, with_, md } from './misc'
8
-import { Btn, Flex, IconBtn, iconTooltip, reloadBtn, useBreakpoint } from './mui'
10
+import { Btn, Flex, IconBtn, iconTooltip, reloadBtn, useBreakpoint, useToggleButton } from './mui'
11
import { TreeItem, TreeView } from '@mui/x-tree-view'
12
import MenuButton from './MenuButton'
13
import AccountForm from './AccountForm'
14
import _ from 'lodash'
15
import { alertDialog, confirmDialog, toast } from './dialog'
14
-import { useSnapState } from './state'
16
+import { state, useSnapState } from './state'
17
import { importAccountsCsv } from './importAccountsCsv'
18
import apiAccounts from '../../src/api.accounts'
19
20
export type Account = ReturnType<typeof apiAccounts.get_accounts>['list'][0]
21
22
export default function AccountsPage() {
21
- const { username } = useSnapState()
23
+ const { username, accountsAsTree } = useSnapState()
24
const { data, reload, element } = useApiEx<typeof apiAccounts.get_accounts>('get_accounts')
25
const [sel, setSel] = useState<string[] | 'new-group' | 'new-user'>([])
26
const selectionMode = Array.isArray(sel)
@@ -73,6 +75,8 @@ export default function AccountsPage() {
75
}, [isSideBreakpoint, sel, selectedAccount])
76
77
const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
78
+ const [showTree, showTreeBtn] = useToggleButton("Show tree", "Show list", () => ({ icon: AccountTree }), accountsAsTree)
79
+ state.accountsAsTree = showTree
80
return element || h(Grid, { container: true, rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 },
81
h(Grid, { item: true, xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5, ...scrollProps },
82
h(Box, {
@@ -99,6 +103,7 @@ export default function AccountsPage() {
103
]
104
}, "Add"),
105
reloadBtn(reload),
106
+ showTreeBtn,
107
list?.length! > 0 && h(Typography, { p: 1 }, `${list!.length} account(s)`),
108
),
109
!list?.length && h(Alert, { severity: 'info' }, md`To access administration <u>remotely</u> you will need to create a user account with admin permission`),
@@ -106,34 +111,38 @@ export default function AccountsPage() {
111
multiSelect: true,
112
sx: { pr: 4, pb: 2, minWidth: '15em' },
113
selected: selectionMode ? sel : [],
114
+ defaultCollapseIcon: h(ExpandMore),
115
+ defaultExpandIcon: h(ChevronRight),
116
onNodeSelect(ev, ids) {
110
- setSel(ids)
117
+ if (!(ev.target as any)?.closest?.('.MuiTreeItem-iconContainer')) // don't select if clicked the expansion button, mostly for mobile users
118
+ setSel(ids)
119
}
120
},
113
- list?.map(ac =>
114
- h(TreeItem, {
115
- key: ac.username,
116
- nodeId: ac.username,
117
- label: h(Box, {
118
- sx: {
119
- display: 'flex',
120
- flexWrap: 'wrap',
121
- padding: '.2em 0',
122
- columnGap: '.5em',
123
- alignItems: 'center',
124
- }
125
- },
126
- account2icon(ac),
127
- (ac.disabled || ac.canLogin === false)
121
+ list && (function recur(thisLevel): ReactNode {
122
+ return thisLevel.map(ac =>
123
+ h(TreeItem, {
124
+ key: ac.username,
125
+ nodeId: ac.username,
126
+ label: h(Box, {
127
+ sx: {
128
+ display: 'flex',
129
+ flexWrap: 'wrap',
130
+ padding: '.2em 0',
131
+ columnGap: '.5em',
132
+ alignItems: 'center',
133
+ }
134
+ },
135
+ account2icon(ac),
136
+ (ac.disabled || ac.canLogin === false)
137
&& iconTooltip(DoNotDisturb, ac.disabled ? "Disabled" : "Disabled by its groups", ac.disabled ? undefined : { color: 'text.secondary' }),
129
- (ac.expire || ac.days_to_live) && h(Schedule),
130
- ac.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin"),
131
- ac.username,
132
- Boolean(ac.belongs?.length) && h(Box, { sx: { color: 'text.secondary', fontSize: 'small' } },
133
- '(', ac.belongs?.join(', '), ')')
134
- ),
135
- })
136
- )
138
+ (ac.expire || ac.days_to_live) && h(Schedule),
139
+ ac.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin"),
140
+ ac.username,
141
+ Boolean(ac.belongs?.length) && h(Box, { sx: { color: 'text.secondary', fontSize: 'small' } },
142
+ '(', ac.belongs?.join(', '), ')')
143
+ ),
144
+ }, showTree && recur(list.filter(x => ac.directMembers?.includes(x.username)))))
145
+ })(showTree ? list.filter(ac => !list.some(x => x.members?.includes(ac.username))) : list)
146
)
147
),
148
isSideBreakpoint && sideContent && h(Grid, { item: true, [sideBreakpoint]: true, maxWidth: '100%', ...scrollProps },
@@ -150,6 +159,7 @@ export default function AccountsPage() {
159
canLogin: true,
160
isGroup: false,
161
members: [],
162
+ directMembers: [],
163
} satisfies Account
164
}
165
admin/src/state.ts
+2
-1
@@ -12,6 +12,7 @@ const INIT = {
12
title: '',
13
config: {} as Dict,
14
selectedFiles: [] as VfsNode[],
15
+ accountsAsTree: false,
16
movingFile: '',
17
vfs: undefined as VfsNode | undefined,
18
loginRequired: false as boolean | number,
@@ -30,7 +31,7 @@ const INIT = {
31
Object.assign(INIT, JSON.parse(localStorage[STORAGE_KEY]||null))
32
export const state = proxy(INIT)
33
33
-const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'monitorWithLog', 'customHtmlSection', 'darkTheme', 'dataTablePersistence']
34
+const SETTINGS_TO_STORE: (keyof typeof state)[] = ['onlinePluginsColumns', 'monitorOnlyFiles', 'monitorWithLog', 'customHtmlSection', 'darkTheme', 'dataTablePersistence', 'accountsAsTree']
35
const storeSettings = _.debounce(() =>
36
localStorage[STORAGE_KEY] = JSON.stringify(_.pick(state, SETTINGS_TO_STORE)), 500, { maxWait: 1000 })
37
for (const k of SETTINGS_TO_STORE)
src/api.accounts.ts
+1
@@ -19,6 +19,7 @@ function prepareAccount(ac: Account | undefined) {
19
adminActualAccess: accountCanLoginAdmin(ac),
20
canLogin: accountHasPassword(ac) ? accountCanLogin(ac) : undefined,
21
invalidated: invalidateSessionBefore.get(ac.username),
22
+ directMembers: Object.values(accountsConfig.get()).filter(a => a.belongs?.includes(ac.username)).map(x => x.username),
23
members: with_(Object.values(accountsConfig.get()), accounts => {
24
const ret = []
25
let news = [ac.username]