| 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 | } |