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) {