main
ts 197 lines 10.1 KB
Raw
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, 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 {
7 AccountTree, ChevronRight, Close, Delete, DoNotDisturb, ExpandMore, Group, MilitaryTech, Person, PersonAdd, Schedule
8 } from '@mui/icons-material'
9 import { newDialog, with_, md, Jsonify } from './misc'
10 import { Btn, execDoneMessage, Flex, IconBtn, iconTooltip, reloadBtn, useBreakpoint, useToggleButton } from './mui'
11 import { TreeItem, SimpleTreeView } 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'
16 import { state, useSnapState } from './state'
17 import { importAccountsCsv } from './importAccountsCsv'
18 import apiAccounts from '../../src/api.accounts'
19
20 export type Account = Jsonify<ReturnType<typeof apiAccounts.get_accounts>['list'][0]>
21
22 const SEP = '\t'
23 const userFromItemId = (itemId?: string) => itemId?.split(SEP).at(-1)
24
25 export default function AccountsPage() {
26 const { username, accountsAsTree } = useSnapState()
27 const { data, reload, element } = useApiEx<typeof apiAccounts.get_accounts>('get_accounts')
28 const [sel, setSel] = useState<string[] | 'new-group' | 'new-user'>([])
29 const selectionMode = Array.isArray(sel)
30 useEffect(() => { // if accounts are reloaded, review the selection to remove elements that don't exist anymore
31 if (Array.isArray(data?.list) && selectionMode)
32 setSel( sel.filter(x => data!.list.find((e:any) => e?.username === userFromItemId(x))) ) // remove elements that don't exist anymore
33 }, [data]) //eslint-disable-line -- Don't fall for its suggestion to add `sel` here: we modify it and declaring it as a dependency would cause a logical loop
34 const list = useMemo(() => data && _.sortBy(data.list, [x => !x.isGroup, x => !x.adminActualAccess, 'username']), [data])
35 const selectedAccount = selectionMode && _.find(list, { username: userFromItemId(sel[0]) })
36 const sideBreakpoint = 'md'
37 const isSideBreakpoint = useBreakpoint(sideBreakpoint)
38
39 const sideContent = !(sel.length > 0) || !list ? null // this clever test is true both when some accounts are selected and when we are in "new account" modes
40 : selectionMode && sel.length > 1 ? h(Fragment, {},
41 h(Flex, {},
42 h(Typography, {variant: 'h6'}, sel.length + " selected"),
43 h(Btn, { onClick: deleteAccounts, icon: Delete }, "Remove"),
44 ),
45 h(List, {},
46 _.uniq(sel.map(userFromItemId)).map(username =>
47 h(ListItem, { key: username },
48 h(ListItemText, {}, username))))
49 )
50 : with_(selectedAccount || newAccount(), a =>
51 h(AccountForm, {
52 account: a,
53 groups: list.filter(x => x.isGroup).map(x => x.username),
54 addToBar: isSideBreakpoint && [
55 h(Box, { sx: { flex: 1 } }),
56 account2icon(a, { fontSize: 'large', sx: { p: 1 }}),
57 // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
58 h(IconBtn, { icon: Close, title: "Close", onClick: selectNone }),
59 ],
60 reload,
61 done(username, saveBtn) {
62 setSel(isSideBreakpoint ? [username] : [])
63 reload()
64 execDoneMessage('', saveBtn)
65 }
66 }))
67 useEffect(() => {
68 if (isSideBreakpoint || !sideContent || !sel.length) return
69 const { close } = newDialog({
70 title: _.isString(sel) ? _.startCase(sel)
71 : sel.length > 1 ? "Multiple selection"
72 : selectedAccount ? (selectedAccount.isGroup ? "Group: " : "User: ") + selectedAccount.username
73 : '?', // never
74 Content: () => sideContent,
75 onClose: selectNone,
76 })
77 return () => void close()
78 }, [isSideBreakpoint, sel, selectedAccount])
79
80 const scrollProps = { height: '100%', display: 'flex', flexDirection: 'column', overflow: 'auto' } as const
81 const [showTree, showTreeBtn] = useToggleButton("Show tree", "Show list", () => ({ icon: AccountTree }), accountsAsTree)
82 state.accountsAsTree = showTree
83 return element || h(Grid, { container: true, sx: { rowSpacing: 1, columnSpacing: 2, top: 0, flex: '1 1 auto', height: 0 } },
84 h(Grid, { size: { xs: 12, [sideBreakpoint]: 5, lg: 4, xl: 5 } as any, sx: scrollProps },
85 h(Box, {
86 sx: {
87 display: 'flex',
88 flexWrap: 'wrap',
89 gap: 2,
90 mb: 2,
91 boxShadow: theme => `0px -8px 4px 10px ${theme.palette.background.paper}`,
92 position: 'sticky',
93 top: 0,
94 zIndex: 2,
95 backgroundColor: 'background.paper',
96 width: 'fit-content',
97 },
98 },
99 h(MenuButton, {
100 variant: 'contained',
101 startIcon: h(PersonAdd),
102 items: [
103 { children: "user", onClick: () => setSel('new-user') },
104 { children: "group", onClick: () => setSel('new-group') },
105 { children: "from CSV", onClick: () => importAccountsCsv(reload) },
106 ]
107 }, "Add"),
108 reloadBtn(reload),
109 showTreeBtn,
110 list?.length! > 0 && h(Typography, { sx: { p: 1 } }, `${list!.length} account(s)`),
111 ),
112 !list?.length && h(Alert, { severity: 'info' }, md`To access administration <u>remotely</u> you will need to create a user account with admin permission`),
113 h(SimpleTreeView<true>, { // true because it's not detecting multiSelect correctly (ts495)
114 multiSelect: true,
115 sx: { pr: 4, pb: 2, minWidth: '15em' },
116 selectedItems: selectionMode ? sel : [],
117 slots: {
118 collapseIcon: ExpandMore,
119 expandIcon: ChevronRight,
120 },
121 onSelectedItemsChange(ev, ids) {
122 if (!(ev?.target as any)?.closest?.('.MuiTreeItem-iconContainer')) // don't select if clicked the expansion button, mostly for mobile users
123 setSel(ids)
124 }
125 },
126 list && (function recur(thisLevel, prefixPath=''): ReactNode {
127 return thisLevel.map(ac =>
128 h(TreeItem, {
129 key: ac.username,
130 itemId: prefixPath + ac.username,
131 label: h(Box, {
132 sx: {
133 display: 'flex',
134 flexWrap: 'wrap',
135 padding: '.2em 0',
136 columnGap: '.5em',
137 alignItems: 'center',
138 }
139 },
140 account2icon(ac),
141 (ac.disabled || ac.canLogin === false)
142 && iconTooltip(DoNotDisturb, ac.disabled ? "Disabled" : "Disabled by its groups", ac.disabled ? undefined : { color: 'text.secondary' }),
143 (ac.expire || ac.days_to_live) && h(Schedule),
144 ac.adminActualAccess && iconTooltip(MilitaryTech, "Can login into Admin"),
145 ac.username,
146 Boolean(ac.belongs?.length) && h(Box, { sx: { color: 'text.secondary', fontSize: 'small' } },
147 '(', ac.belongs?.join(', '), ')')
148 ),
149 }, showTree && recur(list.filter(x => ac.directMembers?.includes(x.username)), prefixPath+ac.username+SEP)))
150 })(showTree ? list.filter(ac => !list.some(x => x.members?.includes(ac.username))) : list)
151 )
152 ),
153 isSideBreakpoint && sideContent && h(Grid, { size: 'grow', sx: { ...scrollProps, maxWidth: '100%' } },
154 h(Card, { sx: { overflow: 'initial' } }, // overflow is incompatible with stickyBar
155 h(CardContent, {}, sideContent)) )
156 )
157
158 function newAccount() {
159 return {
160 username: '',
161 hasPassword: sel === 'new-user',
162 adminActualAccess: false,
163 invalidated: undefined,
164 canLogin: true,
165 canChangePassword: true,
166 isGroup: sel === 'new-group',
167 members: [],
168 directMembers: [],
169 } satisfies Account
170 }
171
172 function selectNone() {
173 setSel([])
174 }
175
176 async function deleteAccounts() {
177 if (typeof sel === 'string') return
178 const toDelete = _.without(_.uniq(sel.map(userFromItemId)), username)
179 if (sel.length > toDelete.length)
180 if (!await confirmDialog(`You cannot ask to delete the account you are using. Continue with the rest?`)) return
181 if (!toDelete.length)
182 return alertDialog("Nothing to delete", 'info')
183 if (!await confirmDialog(`Delete ${toDelete.length} item(s)?`)) return
184 const errors = []
185 for (const username of toDelete)
186 if (!await apiCall('del_account', { username }).then(() => 1, () => 0))
187 errors.push(username)
188 reload()
189 if (errors.length)
190 return alertDialog("The following items couldn't be deleted: " + errors.join(', '), 'error')
191 }
192
193 }
194
195 export function account2icon(ac: Account, props={}) {
196 return h(ac.isGroup ? Group : Person, props)
197 }