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 }