| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import { createElement as h, ReactNode, useEffect, useRef, useState } from 'react' |
| 4 | import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form' |
| 5 | import { Alert, Box } from '@mui/material' |
| 6 | import { apiCall } from './api' |
| 7 | import { alertDialog, useDialogBarColors } from './dialog' |
| 8 | import { apiNewPassword, formatTimestamp, isEqualLax, prefix, reactJoin, useIsMobile, wantArray } from './misc' |
| 9 | import { Btn, Flex, IconBtn, NetmaskField, propsForModifiedValues } from './mui' |
| 10 | import { Account } from './AccountsPage' |
| 11 | import { AutoDelete, Delete } from '@mui/icons-material' |
| 12 | import { state, useSnapState } from './state' |
| 13 | import VfsPathField from './VfsPathField' |
| 14 | import { DateTimeField } from './DateTimeField' |
| 15 | |
| 16 | export default function AccountForm({ account, done, groups, addToBar, reload }: { |
| 17 | account: Account, |
| 18 | groups: string[], |
| 19 | done: (username: string, saveBtn?: HTMLButtonElement) => void, |
| 20 | reload: () => void, |
| 21 | addToBar: ReactNode |
| 22 | }) { |
| 23 | const { username } = useSnapState() |
| 24 | const [values, setValues] = useState<Account & { password?: string, password2?: string }>(account) |
| 25 | const [belongsOptions, setBelongOptions] = useState<string[]>([]) |
| 26 | const isMobile = useIsMobile() |
| 27 | useEffect(() => { |
| 28 | setValues(account) |
| 29 | setBelongOptions(groups.filter(x => x !== account.username )) |
| 30 | if (!isMobile) |
| 31 | ref.current?.querySelector('input')?.focus() |
| 32 | }, [JSON.stringify(account)]) //eslint-disable-line |
| 33 | const add = !account.username |
| 34 | const { isGroup } = values |
| 35 | const ref = useRef<HTMLFormElement>() |
| 36 | const { members } = account |
| 37 | const pluginAuth = account.plugin?.auth |
| 38 | return h(Form, { |
| 39 | formRef: ref, |
| 40 | values, |
| 41 | set(v, k) { |
| 42 | setValues(values => ({ ...values, [k]: v })) |
| 43 | }, |
| 44 | barSx: { gap: 2, width: '100%', ...useDialogBarColors() }, |
| 45 | stickyBar: true, |
| 46 | addToBar: [ |
| 47 | !add && h(IconBtn, { |
| 48 | icon: Delete, |
| 49 | title: "Delete", |
| 50 | confirm: `Delete ${account.username}?`, |
| 51 | ...username === account.username && { disabled: true, title: "Cannot delete current account" }, |
| 52 | onClick: () => apiCall('del_account', { username: account.username }).then(reload) |
| 53 | }), |
| 54 | h(IconBtn, { |
| 55 | icon: AutoDelete, |
| 56 | title: `Invalidate past sessions${prefix('\n(already invalidated sessions before ', formatTimestamp(account.invalidated || 0), ')')}`, |
| 57 | confirm: `Invalidate all sessions up to now for "${account.username}"?`, |
| 58 | doneMessage: true, |
| 59 | onClick: () => apiCall('invalidate_sessions', { username: account.username }).then(reload) |
| 60 | }), |
| 61 | ...wantArray(addToBar), |
| 62 | ], |
| 63 | fields: [ |
| 64 | { k: 'username', label: isGroup ? 'Group name' : undefined, autoComplete: 'off', required: true, md: isGroup && !pluginAuth ? 12 : 4, |
| 65 | getError: v => v !== account.username && apiCall('get_account', { username: v }) |
| 66 | .then(got => got?.username === account.username ? "usernames are case-insensitive" : "already used", () => false), |
| 67 | }, |
| 68 | pluginAuth && { k: '', md: 8, comp: h(Alert, { severity: 'info' }, " Authentication handled by a plugin") }, |
| 69 | !isGroup && !pluginAuth && { k: 'password', xs: 6, md: 4, type: 'password', autoComplete: 'new-password', required: add, |
| 70 | label: add ? "Password" : "Change password" |
| 71 | }, |
| 72 | !isGroup && !pluginAuth && { k: 'password2', xs: 6, md: 4, type: 'password', autoComplete: 'new-password', label: 'Repeat password', |
| 73 | getError: (x, { values }) => (x||'') !== (values.password||'') && "Enter same password" }, |
| 74 | |
| 75 | { k: 'disabled', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Enabled", xs: 12, sm: 6, lg: 4, |
| 76 | helperText: values.disabled || values.canLogin !== false ? "Login is prevented if account is disabled, or all its groups are disabled" |
| 77 | : h(Box, { sx: { color: 'warning.main' }, component: 'span' } as any, // Box.component has ts problems with h() |
| 78 | new Date(account.expire!) < new Date() ? "Login is prevented because account is expired" // use account instead of values, so to use the value currently applied |
| 79 | : "Login is prevented because all of its groups are disabled") |
| 80 | }, |
| 81 | { k: 'ignore_limits', comp: BoolField, xs: 12, sm: 6, lg: 4, |
| 82 | helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" }, |
| 83 | { k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Admin-panel access", xs: 12, sm: 6, lg: 4, |
| 84 | helperText: "To access THIS interface you are using right now", |
| 85 | ...!account.admin && account.adminActualAccess && { value: true, disabled: true, helperText: "This permission is inherited. To disable it, act on the groups." }, |
| 86 | }, |
| 87 | |
| 88 | !isGroup && { k: 'require_password_change', comp: BoolField, xs: 12, sm: 6, lg: 4, helperText: "At next login" }, |
| 89 | { k: 'disable_password_change', label: "Password change", comp: SelectField, xs: 12, sm: 6, lg: isGroup ? 4 : 4, |
| 90 | defaultValue: null, |
| 91 | options: { [`Default (${values.canChangePassword ? 'Allowed' : 'Disabled'})`]: null, "Allowed": false, "Disabled": true }, |
| 92 | }, |
| 93 | |
| 94 | !members ? null |
| 95 | : isGroup && !members.length ? h(Box, {}, "No members") |
| 96 | : members.length > 0 && h(Flex, { gap: 0, flexWrap: 'wrap' }, `${members.length} members: `, |
| 97 | reactJoin(', ', account.members?.map(u => h(groups.includes(u) ? 'i' : 'span', {}, u))), |
| 98 | h(Btn, { |
| 99 | icon: Delete, |
| 100 | confirm: `Delete ${account.members.length} accounts?`, |
| 101 | onClick: () => apiCall('del_account', { username: account.members }).then(reload), |
| 102 | sx: { verticalAlign: 'text-top' } |
| 103 | }), |
| 104 | ), |
| 105 | isGroup && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`), |
| 106 | { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions, sm: 6, lg: 4, |
| 107 | helperText: "Specify groups to inherit permissions from" |
| 108 | + (!isGroup ? '' : ". A group can inherit from another group") |
| 109 | + (belongsOptions.length ? '' : ". Now disabled because there are no groups to select, create one first.") |
| 110 | }, |
| 111 | |
| 112 | { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", sm: 6, lg: 4, placeholder: "any address" }, |
| 113 | !isGroup && { k: 'auto_login_net', comp: NetmaskField, label: "Auto-login by IP address", sm: 6, lg: 4, placeholder: "none" }, |
| 114 | { k: 'redirect', comp: VfsPathField, placeholder: "no", sm: 6, lg: 4, |
| 115 | helperText: "If you want this account to be redirected to a specific folder/address (or even file) at login time" }, |
| 116 | |
| 117 | { k: 'expire', label: "Expiration", sm: 6, lg: 4, comp: DateTimeField, toField: x => x && new Date(x), |
| 118 | helperText: "When expired, login won't be allowed" }, |
| 119 | { k: 'days_to_live', sm: 6, lg: 4, comp: NumberField, step: 'any', min: 1/1000, // 10 minutes |
| 120 | ...values.expire && { xs: 12, disabled: true, sx: { opacity: .2 } }, helperText: "Used to set expiration on first login" }, |
| 121 | { k: 'notes', multiline: true, sm: 6, lg: 4 }, |
| 122 | ], |
| 123 | onError: alertDialog, |
| 124 | save: { |
| 125 | ...propsForModifiedValues(isModifiedConfig(values, account)), |
| 126 | async onClick() { |
| 127 | const { password='', password2, adminActualAccess, hasPassword, invalidated, canLogin, members, ...withoutPassword } = values |
| 128 | const saveBtn = ref.current?.querySelector<HTMLButtonElement>('button.saveBtn') || undefined |
| 129 | if (add) { |
| 130 | const got = await apiCall('add_account', withoutPassword) |
| 131 | if (password) |
| 132 | try { await apiNewPassword(values.username, password) } |
| 133 | catch(e) { |
| 134 | void apiCall('del_account', { username: values.username }) // best effort, don't wait |
| 135 | throw e |
| 136 | } |
| 137 | done(got?.username, saveBtn) |
| 138 | return |
| 139 | } |
| 140 | const got = await apiCall('set_account', { |
| 141 | username: account.username, |
| 142 | changes: withoutPassword, |
| 143 | }) |
| 144 | if (password) |
| 145 | await apiNewPassword(values.username, password) |
| 146 | if (account.username === username) |
| 147 | state.username = values.username |
| 148 | done(got?.username, saveBtn) // username may have been changed, so we pass it back |
| 149 | } |
| 150 | } |
| 151 | }) |
| 152 | } |
| 153 | |
| 154 | export function isModifiedConfig(a: any, b: any) { |
| 155 | return !isEqualLax(a, b, (a,b) => !a && !b || undefined) |
| 156 | } |