fix: admin/accounts: bad mobile ux

Massimo Melina committed Feb 3, 2023 at 16:30 UTC 3ca5b93fec876f3191b49d70eafcd002ad1e688e
2 files changed +80 -70
admin/src/AccountForm.ts
+23 -11
@@ -1,22 +1,25 @@
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, useEffect, useRef, useState } from 'react'
3 +import { createElement as h, ReactNode, useEffect, useRef, useState } from 'react'
4 import { BoolField, Form, MultiSelectField } from '@hfs/mui-grid-form'
5 -import { Box, Button } from '@mui/material'
5 +import { Box } from '@mui/material'
6 import { apiCall } from './api'
7 -import { alertDialog } from './dialog'
8 -import { isEqualLax, modifiedSx } from './misc'
7 +import { alertDialog, toast, useDialogBarColors } from './dialog'
8 +import { IconBtn, isEqualLax, modifiedSx } from './misc'
9 import { Account, account2icon } from './AccountsPage'
10 import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
11 +import { Delete } from '@mui/icons-material'
12 +import { isMobile } from '@hfs/frontend/src/misc'
13
12 -interface FormProps { account: Account, groups: string[], done: (username: string)=>void, close: ()=>void }
13 -export default function AccountForm({ account, done, groups, close }: FormProps) {
14 +interface FormProps { account: Account, groups: string[], done: (username: string)=>void, reload: ()=>void, addToBar: ReactNode }
15 +export default function AccountForm({ account, done, groups, addToBar, reload }: FormProps) {
16 const [values, setValues] = useState<Account & { password?: string, password2?: string }>(account)
17 const [belongsOptions, setBelongOptions] = useState<string[]>([])
18 useEffect(() => {
19 setValues(account)
20 setBelongOptions(groups.filter(x => x !== account.username ))
19 - ref.current?.querySelector('input')?.focus()
21 + if (!isMobile())
22 + ref.current?.querySelector('input')?.focus()
23 }, [JSON.stringify(account)]) //eslint-disable-line
24 const add = !account.username
25 const group = !values.hasPassword
@@ -27,8 +30,16 @@ export default function AccountForm({ account, done, groups, close }: FormProps)
30 set(v, k) {
31 setValues({ ...values, [k]: v })
32 },
33 + barSx: { gap: 2, width: '100%', ...useDialogBarColors() },
34 + stickyBar: true,
35 addToBar: [
31 - h(Button, { onClick: close, sx: { ml: 2 } }, "Close"),
36 + !add && h(IconBtn, {
37 + icon: Delete,
38 + title: "Delete",
39 + confirm: "Delete?",
40 + onClick: () => apiCall('del_account', { username: account.username }).then(() => reload())
41 + }),
42 + addToBar,
43 h(Box, { flex:1 }),
44 account2icon(values, { fontSize: 'large', sx: { p: 1 }})
45 ],
@@ -69,7 +80,8 @@ export default function AccountForm({ account, done, groups, close }: FormProps)
80 throw e
81 }
82 done(got.username)
72 - return alertDialog("Account created", 'success')
83 + toast("Account created", 'success')
84 + return
85 }
86 const got = await apiCall('set_account', {
87 username: account.username,
@@ -77,8 +89,8 @@ export default function AccountForm({ account, done, groups, close }: FormProps)
89 })
90 if (password)
91 await apiNewPassword(username, password)
80 - done(got.username)
81 - return alertDialog("Account modified", 'success')
92 + setTimeout(() => toast("Account modified", 'success'), 1) // workaround: showing a dialog at this point is causing a crash if we are in a dialog
93 + done(got.username) // username may have been changed, so we pass it back
94 }
95 }
96 })
admin/src/AccountsPage.ts
+57 -59
@@ -1,15 +1,15 @@
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 } from "react"
4 -import { apiCall, useApiEx } from './api'
5 -import { Alert, Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6 -import { Delete, Group, MilitaryTech, Person, PersonAdd, Refresh } from '@mui/icons-material'
7 -import { alertDialog, confirmDialog } from './dialog'
8 -import { iconTooltip, onlyTruthy } from './misc'
4 +import { useApiEx } from './api'
5 +import { Alert, Box, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6 +import { Close, Group, MilitaryTech, Person, PersonAdd, Refresh } from '@mui/icons-material'
7 +import { IconBtn, iconTooltip, newDialog, useBreakpoint } from './misc'
8 import { TreeItem, TreeView } from '@mui/lab'
9 import MenuButton from './MenuButton'
10 import AccountForm from './AccountForm'
11 import md from './md'
12 +import _ from 'lodash'
13
14 export interface Account {
15 username: string
@@ -29,10 +29,51 @@ export default function AccountsPage() {
29 if (Array.isArray(data?.list) && selectionMode)
30 setSel( sel.filter(u => data.list.find((e:any) => e?.username === u)) ) // remove elements that don't exist anymore
31 }, [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
32 - if (element)
33 - return element
34 - const { list }: { list: Account[] } = data
35 - return h(Grid, { container: true, maxWidth: '80em' },
32 + const list: Account[] | undefined = data?.list
33 + const selectedAccount = selectionMode && _.find(list, { username: sel[0] })
34 +
35 + function close() {
36 + setSel([])
37 + }
38 +
39 + const sideBreakpoint = 'md'
40 + const isSideBreakpoint = useBreakpoint(sideBreakpoint)
41 +
42 + 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
43 + : selectionMode && sel.length > 1 ? h(Fragment, {},
44 + h(Typography, {}, sel.length + " selected"),
45 + h(List, {},
46 + sel.map(username =>
47 + h(ListItem, { key: username },
48 + h(ListItemText, {}, username))))
49 + )
50 + : h(AccountForm, {
51 + account: selectedAccount || { username: '', hasPassword: sel === 'new-user' },
52 + groups: list.filter(x => !x.hasPassword).map( x => x.username ),
53 + addToBar: isSideBreakpoint && h(IconBtn, { // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
54 + icon: Close,
55 + title: "Close",
56 + onClick: close
57 + }),
58 + reload,
59 + done(username) {
60 + setSel([username])
61 + reload()
62 + }
63 + })
64 + useEffect(() => {
65 + if (isSideBreakpoint || !sideContent || !sel.length) return
66 + return newDialog({
67 + title: _.isString(sel) ? _.startCase(sel)
68 + : sel.length > 1 ? "Multiple selection"
69 + : selectedAccount ? (selectedAccount.hasPassword ? "User: " : "Group: ") + selectedAccount.username
70 + : '?', // never
71 + Content: () => sideContent,
72 + onClose: close,
73 + })
74 + }, [isSideBreakpoint, sel, selectedAccount])
75 +
76 + return element || h(Grid, { container: true, maxWidth: '80em' },
77 h(Grid, { item: true, xs: 12 },
78 h(Box, {
79 display: 'flex',
@@ -54,26 +95,12 @@ export default function AccountsPage() {
95 { children: "user", onClick: () => setSel('new-user') },
96 { children: "group", onClick: () => setSel('new-group') }
97 ]
57 - }, 'Add'),
58 - h(Button, {
59 - disabled: !selectionMode || !sel.length,
60 - startIcon: h(Delete),
61 - async onClick(){
62 - if (!selectionMode) return
63 - if (!await confirmDialog(`You are going to delete ${sel.length} account(s)`))
64 - return
65 - const errors = onlyTruthy(await Promise.all(sel.map(username =>
66 - apiCall('del_account', { username }).then(() => null, () => username) )))
67 - if (errors.length)
68 - return alertDialog(errors.length === sel.length ? "Request failed" : hList("Some accounts were not deleted", errors), 'error')
69 - reload()
70 - }
71 - }, "Remove"),
72 - h(Button, { onClick: reload, startIcon: h(Refresh) }, "Reload"),
73 - list.length > 0 && h(Typography, { p: 1 }, `${list.length} account(s)`),
98 + }, "Add"),
99 + h(IconBtn, { icon: Refresh, title: "Reload", onClick: reload }),
100 + list?.length! > 0 && h(Typography, { p: 1 }, `${list!.length} account(s)`),
101 ) ),
102 h(Grid, { item: true, md: 5 },
76 - !list.length && h(Alert, { severity: 'info' }, md`To access administration _remotely_ you will need to create a user account with admin permission`),
103 + !list?.length && h(Alert, { severity: 'info' }, md`To access administration _remotely_ you will need to create a user account with admin permission`),
104 h(TreeView, {
105 multiSelect: true,
106 sx: { pr: 4, pb: 2, minWidth: '15em' },
@@ -82,7 +109,7 @@ export default function AccountsPage() {
109 setSel(ids)
110 }
111 },
85 - list.map((ac: Account) =>
112 + list?.map((ac: Account) =>
113 h(TreeItem, {
114 key: ac.username,
115 nodeId: ac.username,
@@ -105,37 +132,8 @@ export default function AccountsPage() {
132 )
133 )
134 ),
108 - sel.length > 0 // this clever test is true both when some accounts are selected and when we are in "new account" modes
109 - && h(Grid, { item: true, md: 7 },
110 - h(Card, {},
111 - h(CardContent, {},
112 - selectionMode && sel.length > 1 ? h(Box, {},
113 - h(Typography, {}, sel.length + " selected"),
114 - h(List, {},
115 - sel.map(username =>
116 - h(ListItem, { key: username },
117 - h(ListItemText, {}, username))))
118 - ) : h(AccountForm, {
119 - account: selectionMode && list.find(x => x.username === sel[0])
120 - || { username: '', hasPassword: sel === 'new-user' },
121 - groups: list.filter(x => !x.hasPassword).map( x => x.username ),
122 - close(){ setSel([]) },
123 - done(username) {
124 - setSel([username])
125 - reload()
126 - }
127 - })
128 - )))
129 - )
130 -}
131 -
132 -function hList(heading: string, list: any[]) {
133 - return h(Fragment, {},
134 - heading>'' && h(Typography, {}, heading),
135 - h(List, {},
136 - list.map((text,key) =>
137 - h(ListItem, { key },
138 - typeof text === 'string' ? h(ListItemText, {}, text) : text) ))
135 + isSideBreakpoint && sideContent && h(Grid, { item: true, md: 7 },
136 + h(Card, {}, h(CardContent, {}, sideContent) )),
137 )
138 }
139