admin/accounts: import csv
Massimo Melina committed
Mar 22, 2023 at 19:01 UTC
f93adb4b91c9244b88185bd3dee673995b6b5469
6 files changed
+144
-18
admin/package.json
+1
@@ -16,6 +16,7 @@
16
"@mui/lab": "^5.0.0-alpha.94",
17
"@mui/material": "^5.10.0",
18
"@mui/x-data-grid": "^5.15.1",
19
+ "@gregoranders/csv": "^0.0.12",
20
"react": "^18.2.0",
21
"react-dom": "^18.2.0",
22
"react-router-dom": "^6.2.1",
admin/src/AccountForm.ts
+1
-1
@@ -96,7 +96,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
96
})
97
}
98
99
-async function apiNewPassword(username: string, password: string) {
99
+export async function apiNewPassword(username: string, password: string) {
100
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
101
const res = await createVerifierAndSalt(srp6aNimbusRoutines, username, password)
102
return apiCall('change_srp_others', { username, salt: String(res.s), verifier: String(res.v) }).catch(e => {
admin/src/AccountsPage.ts
+8
-7
@@ -3,7 +3,7 @@
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 { Close, Delete, Group, MilitaryTech, Person, PersonAdd, Refresh } from '@mui/icons-material'
6
+import { Close, Delete, Group, MilitaryTech, Person, PersonAdd } from '@mui/icons-material'
7
import { IconBtn, iconTooltip, newDialog, reloadBtn, useBreakpoint } from './misc'
8
import { TreeItem, TreeView } from '@mui/lab'
9
import MenuButton from './MenuButton'
@@ -13,6 +13,7 @@ import _ from 'lodash'
13
import { Flex } from '@hfs/frontend/src/components'
14
import { alertDialog, confirmDialog } from './dialog'
15
import { useSnapState } from './state'
16
+import { importAccountsCsv } from './importAccountsCsv'
17
18
export interface Account {
19
username: string
@@ -35,11 +36,6 @@ export default function AccountsPage() {
36
}, [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
37
const list: Account[] | undefined = data?.list
38
const selectedAccount = selectionMode && _.find(list, { username: sel[0] })
38
-
39
- function close() {
40
- setSel([])
41
- }
42
-
39
const sideBreakpoint = 'md'
40
const isSideBreakpoint = useBreakpoint(sideBreakpoint)
41
@@ -100,7 +96,8 @@ export default function AccountsPage() {
96
startIcon: h(PersonAdd),
97
items: [
98
{ children: "user", onClick: () => setSel('new-user') },
103
- { children: "group", onClick: () => setSel('new-group') }
99
+ { children: "group", onClick: () => setSel('new-group') },
100
+ { children: "from CSV", onClick: () => importAccountsCsv(reload) },
101
]
102
}, "Add"),
103
reloadBtn(reload),
@@ -143,6 +140,10 @@ export default function AccountsPage() {
140
h(Card, {}, h(CardContent, {}, sideContent) )),
141
)
142
143
+ function close() {
144
+ setSel([])
145
+ }
146
+
147
async function deleteAccounts() {
148
if (sel.length > _.pull(sel, username).length)
149
await alertDialog("Won't delete current account", 'warning')
admin/src/importAccountsCsv.ts
new
+123
@@ -0,0 +1,123 @@
1
+import { alertDialog, formDialog, newDialog } from './dialog'
2
+import { createElement as h, Fragment, useEffect, useState } from 'react'
3
+import { Group, Upload } from '@mui/icons-material'
4
+import { Box } from '@mui/material'
5
+import { apiCall } from './api'
6
+import { apiNewPassword } from './AccountForm'
7
+import { IconProgress, prefix, readFile, selectFiles } from './misc'
8
+import { NumberField, BoolField } from '@hfs/mui-grid-form'
9
+import Parser from '@gregoranders/csv';
10
+
11
+export async function importAccountsCsv(cb?: () => void) {
12
+ selectFiles(async list => {
13
+ const f = list?.[0]
14
+ if (!f) return
15
+ const txt = await readFile(f)
16
+ if (!txt) return
17
+ const parser = new Parser()
18
+ const rows = parser.parse(txt.trim())
19
+ const colField = { comp: NumberField, min: 1, max: 9, xs: 6, typing: true, }
20
+ const initialConfig = {
21
+ skipFirstLines: 0,
22
+ usernameColumn: 1,
23
+ passwordColumn: 2,
24
+ groupColumn: 3,
25
+ overwriteExistingAccounts: false,
26
+ }
27
+ const cfg = await formDialog<typeof initialConfig>({
28
+ title: "Import accounts from CSV",
29
+ dialogProps: { maxWidth: 'sm' },
30
+ values: initialConfig,
31
+ form: values => {
32
+ const row = rows[values.skipFirstLines || 0]
33
+ const rec = getRec(row, { ...initialConfig, ...values })
34
+ return {
35
+ save: { startIcon: h(Upload), children: 'Go' },
36
+ fields: [
37
+ h(Box, { p: 1 }, "Total lines:", rows.length),
38
+ { k: 'skipFirstLines', comp: NumberField, min: 0, max: rows.length-1, typing: true,
39
+ helperText: h(Fragment, {}, "First line: ", h('code', {}, row) ),
40
+ },
41
+ { k: 'usernameColumn', ...colField,
42
+ helperText: h(Fragment, {}, "First username: ", rec.u),
43
+ },
44
+ { k: 'passwordColumn', ...colField,
45
+ helperText: h(Fragment, {}, "First password: ", rec.p),
46
+ },
47
+ { k: 'groupColumn', ...colField,
48
+ helperText: h(Fragment, {}, "First group: ", rec.g),
49
+ },
50
+ { k: 'overwriteExistingAccounts', comp: BoolField, xs: 6 },
51
+ ],
52
+ }
53
+ },
54
+ })
55
+ if (!cfg) return
56
+ const close = newDialog({
57
+ title: "Importing...",
58
+ Content() {
59
+ const [progress, setProgress] = useState(0)
60
+ const [record, setRecord] = useState<undefined | ReturnType<typeof getRec>>()
61
+ useEffect(() => {
62
+ let stop = false
63
+ setTimeout(async () => {
64
+ if (stop) return
65
+ let bad = 0
66
+ let already =0
67
+ let skip = cfg.skipFirstLines
68
+ const total = rows.length - skip
69
+ try {
70
+ let i = 0
71
+ for (const row of rows) {
72
+ if (stop) return
73
+ if (skip) {
74
+ skip--
75
+ continue
76
+ }
77
+ const rec = getRec(row, cfg)
78
+ setRecord(rec)
79
+ setProgress(i++ / total)
80
+ await apiCall('add_account', {
81
+ username: rec.u,
82
+ belongs: rec.g?.split(','),
83
+ overwrite: cfg.overwriteExistingAccounts
84
+ }).then(() => {
85
+ if (rec.p)
86
+ return apiNewPassword(rec.u, rec.p)
87
+ }, e => {
88
+ if (e.code === 409)
89
+ return already++
90
+ bad++
91
+ })
92
+ }
93
+ }
94
+ finally {
95
+ close()
96
+ const good = total - bad - already
97
+ const msg = "Results: " + [
98
+ prefix('', bad, " failed"),
99
+ prefix('', good, " succeeded"),
100
+ prefix('', already, " skipped because already present"),
101
+ ].filter(Boolean).join(', ')
102
+ alertDialog(msg, !good && bad ? 'error' : (bad || already) ? 'warning' : 'success')
103
+ cb?.()
104
+ }
105
+ })
106
+ return () => { stop = true }
107
+ }, [])
108
+ return h(Box, { display: 'flex', gap: 2, alignItems: 'center' },
109
+ h(IconProgress, { icon: Group, progress }),
110
+ record?.u,
111
+ )
112
+ }
113
+ })
114
+
115
+ function getRec(row: string[], config: typeof initialConfig) {
116
+ return {
117
+ u: row[config.usernameColumn - 1],
118
+ p: row[config.passwordColumn - 1],
119
+ g: row[config.groupColumn - 1],
120
+ }
121
+ }
122
+ }, { multiple: false, accept: '.csv' })
123
+}
src/api.accounts.ts
+9
-5
@@ -50,15 +50,19 @@ const apis: ApiHandlers = {
50
changes.admin = undefined
51
else if (admin !== undefined && typeof admin !== 'boolean')
52
return new ApiError(HTTP_BAD_REQUEST, "invalid admin")
53
- const acc = setAccount(username, changes)
53
+ const acc = getAccount(username)
54
+ if (!acc)
55
+ return new ApiError(HTTP_BAD_REQUEST)
56
+ setAccount(acc, changes)
57
if (changes.username && ctx.session)
58
ctx.session.username = changes.username
56
- return acc ? _.pick(acc, 'username') : new ApiError(HTTP_BAD_REQUEST)
59
+ return _.pick(acc, 'username')
60
},
61
59
- add_account({ username, ...rest }) {
60
- if (getAccount(username))
61
- return new ApiError(HTTP_CONFLICT)
62
+ add_account({ overwrite, username, ...rest }) {
63
+ const existing = getAccount(username)
64
+ if (existing)
65
+ return overwrite ? setAccount(existing, rest) : new ApiError(HTTP_CONFLICT)
66
const acc = addAccount(username, rest)
67
return acc ? _.pick(acc, 'username') : new ApiError(HTTP_BAD_REQUEST)
68
},
src/perm.ts
+2
-5
@@ -142,17 +142,14 @@ export function addAccount(username: string, props: Partial<Account>) {
142
return copy
143
}
144
145
-export function setAccount(username: string, changes: Partial<Account>) {
146
- const acc = getAccount(username)
147
- if (!acc)
148
- return false
145
+export function setAccount(acc: Account, changes: Partial<Account>) {
146
const rest = _.pick(changes, assignableProps)
147
for (const [k,v] of Object.entries(rest))
148
if (!v)
149
rest[k as keyof Account] = undefined
150
Object.assign(acc, rest)
151
if (changes.username)
155
- renameAccount(username, changes.username)
152
+ renameAccount(acc.username, changes.username)
153
saveAccountsAsap()
154
return acc
155
}