admin/accounts: ux: proper form validation

Massimo Melina committed Mar 15, 2022 at 10:42 UTC cd309d583edf0e7e50b11d36a9ae1013428987ad
4 files changed +59 -41
admin/src/AccountsPage.ts
+24 -31
@@ -170,9 +170,12 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
170 barSx: { width: 'initial', justifyContent: 'space-between' },
171 addToBar: [ account2icon(values, { fontSize: 'large', sx: { p: 1 }}) ],
172 fields: [
173 - { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off' },
174 - !group && { k: 'password', comp: StringField, md: 6, type: 'password', autoComplete: 'new-password', label: add ? 'Password' : 'Change password' },
175 - !group && { k: 'password2', comp: StringField, md: 6, type: 'password', autoComplete: 'off', label: 'Repeat password' },
173 + { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off', validate: x => x>'' || "Required" },
174 + !group && { k: 'password', comp: StringField, md: 6, type: 'password', autoComplete: 'new-password', label: add ? "Password" : "Change password",
175 + validate: x => x>'' || !add || "Please provide a password"
176 + },
177 + !group && { k: 'password2', comp: StringField, md: 6, type: 'password', autoComplete: 'off', label: 'Repeat password',
178 + validate: (x, { values }) => x === values.password || "Enter same password" },
179 { k: 'ignore_limits', comp: BoolField,
180 helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
181 { k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Permission to access Admin interface",
@@ -184,41 +187,31 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
187 helperText: "Options and permissions of the selected groups will be applied to this account. "
188 + (belongsOptions.length ? '' : "There are no groups available, create one first.") }
189 ],
190 + onError: alertDialog,
191 save: {
192 disabled: isEqualLax(values, account),
193 async onClick() {
194 + const { password='', password2, ...withoutPassword } = values
195 const { username } = values
191 - if (!username)
192 - return alertDialog(`Username cannot be empty`, 'warning')
193 - const { hasPassword, password, password2, ...withoutPassword } = values
194 - if (password !== password2)
195 - return alertDialog("You entered 2 different passwords, please fix", 'error')
196 - try {
197 - if (add) {
198 - if (hasPassword && !password)
199 - return alertDialog("Please provide a password", 'warning')
200 - await apiCall('add_account', withoutPassword)
201 - if (password)
202 - try { await apiNewPassword(username, password) }
203 - catch(e) {
204 - apiCall('del_account', { username }).then() // best effort, don't wait
205 - throw e
206 - }
207 - done(username)
208 - return alertDialog("Account created", 'success')
209 - }
210 - await apiCall('set_account', {
211 - username: account.username,
212 - changes: withoutPassword,
213 - })
196 + if (add) {
197 + await apiCall('add_account', withoutPassword)
198 if (password)
215 - await apiNewPassword(username, password)
199 + try { await apiNewPassword(username, password) }
200 + catch(e) {
201 + apiCall('del_account', { username }).then() // best effort, don't wait
202 + throw e
203 + }
204 done(username)
217 - return alertDialog("Account modified", 'success')
218 - }
219 - catch (e) {
220 - return alertDialog(e as Error)
205 + return alertDialog("Account created", 'success')
206 }
207 + await apiCall('set_account', {
208 + username: account.username,
209 + changes: withoutPassword,
210 + })
211 + if (password)
212 + await apiNewPassword(username, password)
213 + done(username)
214 + return alertDialog("Account modified", 'success')
215 }
216 }
217 })
admin/src/FileCard.ts
+3 -4
@@ -7,7 +7,6 @@ import { BoolField, DisplayField, Field, FieldProps, Form, MultiSelectField, Sel
7 import { apiCall, useApi } from './api'
8 import { formatBytes, isEqualLax, onlyTruthy } from './misc'
9 import { reloadVfs, Who } from './VfsPage'
10 -import { alertDialog } from './dialog'
10 import md from './md'
11 import _ from 'lodash'
12
@@ -65,8 +64,6 @@ function FileForm({ file }: { file: ReturnType<typeof useSnapState>['selectedFil
64 save: {
65 disabled: isEqualLax(values, file),
66 async onClick() {
68 - if (file.id !== '/' && !values.name)
69 - return alertDialog(`Name cannot be empty`, 'warning')
67 const props = _.pickBy(values, (v,k) =>
68 v !== file[k as keyof typeof values])
69 if (!props.masks)
@@ -82,7 +79,9 @@ function FileForm({ file }: { file: ReturnType<typeof useSnapState>['selectedFil
79 }
80 },
81 fields: [
85 - !isRoot && { k: 'name', helperText: source && "You can decide a name that's different from the one on your disk" },
82 + !isRoot && { k: 'name', validate: x => x>'' || `Required`,
83 + helperText: source && "You can decide a name that's different from the one on your disk",
84 + },
85 hasSource && { k: 'source', comp: DisplayField },
86 { k: 'can_read', label:"Who can download", md: showCanSee && 6, comp: WhoField, parent, accounts, inherit: inheritedPerms.can_read,
87 helperText: "Who cannot download also cannot see in list"
admin/src/Form.ts
+31 -5
@@ -17,14 +17,20 @@ import { Save } from '@mui/icons-material'
17 import { LoadingButton } from '@mui/lab'
18 import _ from 'lodash'
19
20 -interface FieldDescriptor { k:string, comp?: any, label?: string | ReactElement, [extraProp:string]:any }
20 +interface FieldDescriptor {
21 + k:string
22 + comp?: any
23 + label?: string | ReactElement
24 + validate?: (v: any, extra:any) => string | boolean
25 + [extraProp:string]:any
26 +}
27
28 // it seems necessary to cast (Multi)SelectField sometimes
29 export type Field<T> = FC<FieldProps<T>>
30
31 interface FormProps {
32 fields: (FieldDescriptor | ReactElement | null | undefined | false)[]
27 - defaults?: (f:FieldDescriptor) => Dict | void
33 + defaults?: (f:FieldDescriptor) => Dict | any
34 values: Dict
35 set: (v: any, field: FieldDescriptor) => void
36 save?: Partial<Parameters<typeof Button>[0]>
@@ -36,11 +42,21 @@ interface FormProps {
42 }
43 export function Form({ fields, values, set, defaults, save, stickyBar, addToBar=[], barSx, formRef, onError, ...rest }: FormProps) {
44 const [loading, setLoading] = useStateMounted(false)
45 + const [errors, setErrors] = useStateMounted<Dict>({})
46 const onClick = save?.onClick
47 if (onClick)
48 save.onClick = async function (ev) {
49 setLoading(true)
43 - try { return await onClick(ev) }
50 + try {
51 + for (const f of fields) {
52 + if (!f || isValidElement(f) || !f.k || !f.validate) continue
53 + const res = await f.validate(values?.[f.k], { values, fields })
54 + if (res !== true)
55 + return setErrors({ [f.k]: res || true })
56 + }
57 + setErrors({})
58 + return await onClick(ev)
59 + }
60 catch(e) { onError?.(e) }
61 finally { setLoading(false) }
62 }
@@ -76,21 +92,30 @@ export function Form({ fields, values, set, defaults, save, stickyBar, addToBar=
92 return h(Grid, { key: idx, item: true, xs: 12 }, row)
93 let field = row
94 const { k, onChange } = field
95 + let error = errors[k]
96 + if (error === true)
97 + error = "Not valid"
98 if (k) {
99 field = {
100 value: values?.[k],
101 ...field,
102 + error: field.error || Boolean(error) || undefined,
103 onChange(v:any) {
104 if (onChange)
105 v = onChange(v)
106 set(v, field)
107 },
108 }
109 + if (error)
110 + field.helperText = field.helperText ? h(Fragment, {}, error, h('br'), field.helperText)
111 + : error
112 if (field.label === undefined)
113 field.label = _.capitalize(k.replaceAll('_', ' '))
114 _.defaults(field, defaults?.(field))
115 }
93 - const { xs=12, sm, md, lg, xl, comp=StringField, ...rest } = field
116 + const { xs=12, sm, md, lg, xl, comp=StringField,
117 + validate, // don't propagate
118 + ...rest } = field
119 return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
120 isValidElement(comp) ? comp : h(comp, rest) )
121 })
@@ -119,8 +144,9 @@ export interface FieldProps<T> {
144 label?: string | ReactElement
145 value?: T
146 onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
122 - toField?: (v: any) => T,
147 + toField?: (v: any) => T
148 fromField?: (v: T) => any
149 + error?: true
150 [rest: string]: any
151 }
152
admin/src/dialog.ts
+1 -1
@@ -46,7 +46,7 @@ const type2ico = {
46
47 export async function alertDialog(msg: ReactElement | string | Error, type:AlertType='info', icon?: ReactElement) {
48 if (msg instanceof Error) {
49 - msg = String(msg)
49 + msg = msg.message || String(msg)
50 type = 'error'
51 }
52 return new Promise(resolve => newDialog({