admin/login: better ux
Massimo Melina committed
Mar 12, 2022 at 12:30 UTC
62e371b65c398f36669efab59d76d2c7de4070fd
6 files changed
+83
-59
admin/src/ConfigPage.ts
+1
-1
@@ -31,7 +31,7 @@ export default function ConfigPage() {
31
if (v || config[k])
32
state.config[k] = v
33
},
34
- sticky: true,
34
+ stickyBar: true,
35
save: {
36
onClick: save,
37
disabled: !Object.keys(changes).length,
admin/src/Form.ts
+50
-25
@@ -1,9 +1,8 @@
1
// This file is part of HFS - Copyright 2021-2022, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, FC, Fragment, isValidElement, ReactElement, useEffect, useState } from 'react'
3
+import { createElement as h, FC, Fragment, isValidElement, ReactElement, ReactNode, useEffect, useState } from 'react'
4
import {
5
Box,
6
- Button,
6
FormControl,
7
FormControlLabel, FormHelperText,
8
FormLabel,
@@ -13,8 +12,9 @@ import {
12
Switch,
13
TextField
14
} from '@mui/material'
16
-import { Dict } from './misc'
15
+import { Dict, useStateMounted } from './misc'
16
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 }
@@ -28,37 +28,61 @@ interface FormProps {
28
values: Dict
29
set: (v: any, field: FieldDescriptor) => void
30
save?: Dict
31
- sticky?: boolean
32
- addToBar?: ReactElement[]
31
+ stickyBar?: boolean
32
+ addToBar?: ReactNode[]
33
barSx?: Dict
34
- [rest:string]: any,
34
+ [rest:string]: any
35
}
36
-export function Form({ fields, values, set, defaults, save, sticky, addToBar=[], barSx, ...rest }: FormProps) {
36
+export function Form({ fields, values, set, defaults, save, stickyBar, addToBar=[], barSx, ...rest }: FormProps) {
37
+ const [loading, setLoading] = useStateMounted(false)
38
+ const onClick = save?.onClick
39
+ if (onClick)
40
+ save.onClick = async function () {
41
+ setLoading(true)
42
+ try { return await onClick(this, arguments) }
43
+ finally { setLoading(false) }
44
+ }
45
+
46
+ const [pendingSubmit, setPendingSubmit] = useStateMounted(false)
47
+ useEffect(() => {
48
+ if (!pendingSubmit) return
49
+ setTimeout(save?.onClick)
50
+ setPendingSubmit(false)
51
+ }, [pendingSubmit]) //eslint-disable-line
52
+
53
+ const bar = save && h(Box, {
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ sx: Object.assign({},
57
+ stickyBar && { width: 'fit-content', zIndex: 2, backgroundColor: 'background.paper', position: 'sticky', top: 0 },
58
+ barSx)
59
+ },
60
+ h(LoadingButton, {
61
+ variant: 'contained',
62
+ startIcon: h(Save),
63
+ children: "Save",
64
+ loading,
65
+ ...save,
66
+ }),
67
+ ...addToBar,
68
+ )
69
+
70
return h('form', {
71
onSubmit(ev) {
72
ev.preventDefault()
73
},
74
onKeyDown(ev) {
75
if (!save?.disabled && (ev.ctrlKey || ev.metaKey) && ev.key === 'Enter')
43
- save?.onClick?.()
76
+ setPendingSubmit(true) // we need to let outer component perform its state changes
77
}
78
},
46
- h(Box, rest,
47
- save && h(Box, {
48
- display: 'flex',
49
- gap: 2,
50
- alignItems: 'center',
51
- sx: Object.assign({ mb: 3, width: 'fit-content' },
52
- sticky && { zIndex: 2, backgroundColor: 'background.paper', position: 'sticky', top: 0 },
53
- barSx)
54
- },
55
- h(Button, {
56
- variant: 'contained',
57
- startIcon: h(Save),
58
- ...save,
59
- }, 'Save'),
60
- ...addToBar,
61
- ),
79
+ h(Box, {
80
+ display: 'flex',
81
+ flexDirection: 'column',
82
+ gap: 3,
83
+ ...rest
84
+ },
85
+ stickyBar && bar,
86
h(Grid, { container:true, rowSpacing:3, columnSpacing:1 },
87
fields.map((row, idx) => {
88
if (!row)
@@ -81,7 +105,8 @@ export function Form({ fields, values, set, defaults, save, sticky, addToBar=[],
105
return h(Grid, { key: k, item: true, xs, sm, md, lg, xl },
106
isValidElement(comp) ? comp : h(comp, rest) )
107
})
84
- )
108
+ ),
109
+ !stickyBar && bar,
110
)
111
)
112
}
admin/src/LoginRequired.ts
+20
-26
@@ -4,7 +4,6 @@ import { Center } from './misc'
4
import { Form } from './Form'
5
import { apiCall } from './api'
6
import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
7
-import { LoadingButton } from '@mui/lab'
7
import { Alert } from '@mui/material'
8
9
export function LoginRequired({ children }: any) {
@@ -16,41 +15,36 @@ export function LoginRequired({ children }: any) {
15
16
function LoginForm() {
17
const [values, setValues] = useState({ username: '', password: '' })
19
- const [loading, setLoading] = useState(false)
18
const [error, setError] = useState('')
21
- return h(Center, { flexDirection: 'column', gap: 2 },
19
+ return h(Center, {},
20
h(Form, {
21
values: {},
22
set(v, { k }) {
23
setValues({ ...values, [k]: v })
24
},
25
fields: [
28
- { k: 'username', autoComplete: 'username' },
26
+ { k: 'username', autoComplete: 'username', autoFocus: true },
27
{ k: 'password', type: 'password', autoComplete: 'current-password' },
30
- ]
31
- }),
32
- h(LoadingButton, {
33
- variant: 'contained',
34
- loading,
35
- async onClick() {
36
- const { username, password } = values
37
- if (!username || !password) return
38
- setLoading(true)
39
- try {
40
- await login(username, password)
41
- setError('')
42
- state.loginRequired = false
43
- state.username = username
44
- }
45
- catch(e) {
46
- setError(String(e))
47
- }
48
- finally {
49
- setLoading(false)
28
+ ],
29
+ addToBar: [ error && h(Alert, { severity: 'error', sx: { flex: 1 } }, error) ],
30
+ save: {
31
+ children: "Enter",
32
+ startIcon: null,
33
+ async onClick() {
34
+ const { username, password } = values
35
+ if (!username || !password) return
36
+ try {
37
+ setError('')
38
+ await login(username, password)
39
+ state.loginRequired = false
40
+ state.username = username
41
+ }
42
+ catch(e) {
43
+ setError(String(e))
44
+ }
45
}
46
}
52
- }, "Enter"),
53
- h(Alert, { sx: { visibility: error ? '' : 'hidden' }, severity: 'error' }, error)
47
+ })
48
)
49
}
50
server/src/api.auth.ts
+7
-4
@@ -13,7 +13,7 @@ import { prepareState } from './middlewares'
13
14
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
15
const srpSession = new SRPServerSession(srp6aNimbusRoutines)
16
-const ongoingLogins:Record<string,SRPServerSessionStep1> = {}
16
+const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
17
18
// centralized log-in state
19
function loggedIn(ctx:Koa.Context, username: string | false) {
@@ -27,6 +27,7 @@ function loggedIn(ctx:Koa.Context, username: string | false) {
27
}
28
s.username = username
29
prepareState(ctx, async ()=>{}) // updating the state is necessary to send complete session data so that frontend shows admin button
30
+ delete s.login
31
ctx.cookies.set('csrf', randomId(), { signed:false, httpOnly: false })
32
}
33
@@ -85,15 +86,17 @@ export const loginSrp2: ApiHandler = async ({ pubKey, proof }, ctx) => {
86
try {
87
const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
88
loggedIn(ctx, username)
88
- const acc = getAccount(username)
89
- return { proof: String(M2), redirect: acc?.redirect, ...await refresh_session({},ctx) }
89
+ return {
90
+ proof: String(M2),
91
+ redirect: ctx.state.account?.redirect,
92
+ ...await refresh_session({},ctx)
93
+ }
94
}
95
catch(e) {
96
return new ApiError(401, String(e))
97
}
98
finally {
99
delete ongoingLogins[sid]
96
- delete ctx.session.login
100
}
101
}
102
server/src/middlewares.ts
-1
@@ -113,7 +113,6 @@ function applyBlock(socket: Socket) {
113
}
114
115
export const prepareState: Koa.Middleware = async (ctx, next) => {
116
- ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
116
ctx.state.account = getAccount(getCurrentUsername(ctx))
117
await next()
118
}
server/src/vfs.ts
+5
-2
@@ -3,13 +3,14 @@
3
import fs from 'fs/promises'
4
import { basename } from 'path'
5
import { isMatch } from 'micromatch'
6
-import { dirTraversal, enforceFinal, isDirectory, typedKeys } from './misc'
6
+import { dirTraversal, enforceFinal, getOrSet, isDirectory, typedKeys } from './misc'
7
import Koa from 'koa'
8
import glob from 'fast-glob'
9
import _ from 'lodash'
10
import { setConfig, subscribeConfig } from './config'
11
import { FORBIDDEN, IS_WINDOWS } from './const'
12
import events from './events'
13
+import { getCurrentUsernameExpanded } from './perm'
14
15
const WHO_ANYONE = true
16
const WHO_NO_ONE = false
@@ -233,7 +234,9 @@ function renameUnderPath(rename:undefined | Record<string,string>, path: string)
234
function matchWho(who: Who, ctx: Koa.Context) {
235
return who === WHO_ANYONE
236
|| who === WHO_ANY_ACCOUNT && Boolean(ctx.state.account)
236
- || Array.isArray(who) && who.some(u => ctx.state.usernames.includes(u) )
237
+ || Array.isArray(who) && (() => // check if I or any ancestor match `who`, but cache ancestors' usernames inside context state
238
+ getOrSet(ctx.state, 'usernames', () => getCurrentUsernameExpanded(ctx)).some((u:string) =>
239
+ who.includes(u) ))()
240
}
241
242
export function cantReadStatusCode(node: VfsNode) {