* feat(web): multi-user accounts with fixed admin/operator/viewer roles - migration 6 adds web_users and copies the existing single admin with the same id and password hash, so upgrades need no action and existing sessions stay logged in - table-driven permission middleware on every business route (default deny), 403 for missing permission, 401 when logged out - hooks/override and secrets redacted for non-admins; operators may only pick registered bilibili accounts for templates - /v1/web-users management API, /v1/me and self password change - login rate limit: 5 consecutive failures lock the username for 30s - revoked sessions cut long-lived websocket/stream responses Refs #1717 * feat(cli): add `biliup user list` and `biliup user reset-password` Refs #1717 * feat(web-ui): user management page, account menu and editable login name - /users page (super admin only): create users, change role, reset password, disable, force logout, delete; table on desktop, cards on mobile, with loading/empty/error states - sidebar filters menu entries by the permissions from /v1/me and shows a no-access state for pages the role cannot open - account menu in the sidebar footer with change password and logout, hidden when --auth is off - 403 responses show a warning toast instead of redirecting - login page username is editable (default biliup) Refs #1717 * feat(web-ui): hide actions the current role cannot perform Streamer add/edit/delete/pause, hook and override editing, template and upload actions, bilibili account management and config saving are only rendered for roles that have the matching permission. Account and type tree requests are skipped for roles that would get 403. Refs #1717 * fix(web-ui): wait for permissions on restricted pages, surface pause errors - pages that need a permission not every role has render a spinner until /v1/me returns, so roles without it never fire requests doomed to 403 - user management is hidden when --auth is off, with an explanation if the page is opened directly - the pause button goes through the shared response handler, so a 403 shows the permission toast instead of failing silently - permission errors sent as plain text keep their message Refs #1717 * fix(web-ui): keep user actions reachable on tablet and narrow desktop Refs #1717 * fix(py): handle the user subcommand in the stream-gears entry point Refs #1717
190 lines
5.3 KiB
TypeScript
190 lines
5.3 KiB
TypeScript
import React, { useRef, useState } from 'react'
|
|
import {
|
|
requestDelete,
|
|
sendRequest,
|
|
} from '../lib/api-streamer'
|
|
import {
|
|
Button,
|
|
Form,
|
|
List,
|
|
Modal,
|
|
Notification,
|
|
Radio,
|
|
RadioGroup,
|
|
Row,
|
|
SideSheet,
|
|
Toast,
|
|
Typography,
|
|
} from '@douyinfe/semi-ui'
|
|
import AvatarCard from './AvatarCard'
|
|
import { IconPlusCircle } from '@douyinfe/semi-icons'
|
|
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
|
|
import useSWRMutation from 'swr/mutation'
|
|
import { useBiliUsers } from '../lib/use-streamers'
|
|
import QRcode from '@/app/ui/QRcode'
|
|
import { useWindowWidth } from '../lib/useIsMobile';
|
|
|
|
type UserListProps = {
|
|
onCancel?: (e: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>) => void
|
|
visible?: boolean
|
|
children?: React.ReactNode
|
|
}
|
|
const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
|
|
const { trigger } = useSWRMutation('/v1/users', sendRequest)
|
|
const { trigger: deleteUser } = useSWRMutation('/v1/users', requestDelete)
|
|
const { biliUsers: list } = useBiliUsers()
|
|
const [modalVisible, setVisible] = useState(false)
|
|
const [confirmLoading, setConfirmLoading] = useState(false)
|
|
const width = useWindowWidth()
|
|
const showDialog = () => {
|
|
setVisible(true)
|
|
}
|
|
|
|
const addUser = async (value: any) => {
|
|
setConfirmLoading(true)
|
|
try {
|
|
await trigger({
|
|
value: value
|
|
})
|
|
setVisible(false)
|
|
Toast.success('创建成功')
|
|
} catch (e: any) {
|
|
let messageObj = e.message
|
|
try {
|
|
messageObj = JSON.parse(messageObj).error
|
|
} catch (e: any) {
|
|
console.log(e)
|
|
}
|
|
return Notification.error({
|
|
title: '创建失败',
|
|
content: (
|
|
<Typography.Paragraph style={{ maxWidth: 450 }}>{messageObj}</Typography.Paragraph>
|
|
),
|
|
// theme: 'light',
|
|
// duration: 0,
|
|
style: { width: 'min-content' },
|
|
})
|
|
} finally {
|
|
setConfirmLoading(false)
|
|
}
|
|
}
|
|
const handleOk = async () => {
|
|
let values = await api.current?.validate()
|
|
await addUser(values?.value)
|
|
}
|
|
const handleCancel = () => {
|
|
setVisible(false)
|
|
}
|
|
const handleAfterClose = () => {
|
|
// noop
|
|
}
|
|
const updateList = async (id: number) => {
|
|
try {
|
|
await deleteUser(id)
|
|
Toast.success('删除成功')
|
|
} catch (e: any) {
|
|
Notification.error({
|
|
title: '删除失败',
|
|
content: <Typography.Paragraph style={{ maxWidth: 450 }}>{e.message}</Typography.Paragraph>,
|
|
// theme: 'light',
|
|
// duration: 0,
|
|
style: { width: 'min-content' },
|
|
})
|
|
}
|
|
}
|
|
const api = useRef<FormApi>(undefined)
|
|
const [value, setValue] = useState()
|
|
const [panel, setPanel] = useState(<></>)
|
|
const onChange = (e: any) => {
|
|
setValue(e.target.value)
|
|
if (e.target.value === 2) {
|
|
setPanel(<QRcode onSuccess={addUser} />)
|
|
}
|
|
if (e.target.value === 1) {
|
|
setPanel(
|
|
<Form getFormApi={formApi => (api.current = formApi)}>
|
|
<Form.Input
|
|
field="value"
|
|
label="Cookie路径"
|
|
trigger="blur"
|
|
placeholder="cookies.json"
|
|
rules={[{ required: true }]}
|
|
/>
|
|
</Form>
|
|
)
|
|
}
|
|
}
|
|
return (
|
|
<SideSheet
|
|
title={<Typography.Title heading={4}>B 站账号</Typography.Title>}
|
|
visible={visible}
|
|
width={Math.min(448, width ?? Number.MIN_VALUE)}
|
|
footer={
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Button
|
|
onClick={showDialog}
|
|
icon={<IconPlusCircle size="large" />}
|
|
style={{ marginRight: 4 }}
|
|
>
|
|
新增
|
|
</Button>
|
|
</div>
|
|
}
|
|
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
|
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
|
onCancel={onCancel}
|
|
>
|
|
<List
|
|
className="component-list-demo-booklist"
|
|
dataSource={list}
|
|
split={false}
|
|
size="small"
|
|
style={{ flexBasis: '100%', flexShrink: 0 }}
|
|
renderItem={
|
|
item => (
|
|
<AvatarCard
|
|
url={item.face}
|
|
abbr={item.name}
|
|
label={item.name}
|
|
value={item.value}
|
|
onRemove={async () => await updateList(item.id)}
|
|
/>
|
|
)
|
|
// <div style={{ margin: 4 }} className='list-item'>
|
|
// <Button type='danger' theme='borderless' icon={<IconMinusCircle />} onClick={() => updateList(item)} style={{ marginRight: 4 }} />
|
|
// {item}
|
|
// </div>
|
|
}
|
|
/>
|
|
|
|
<Modal
|
|
title="新建"
|
|
visible={modalVisible}
|
|
onOk={handleOk}
|
|
style={{ width: 'min(600px, 90vw)' }}
|
|
afterClose={handleAfterClose} //>=1.16.0
|
|
onCancel={handleCancel}
|
|
closeOnEsc={true}
|
|
confirmLoading={confirmLoading}
|
|
okButtonProps={{ disabled: value === 2 }}
|
|
bodyStyle={{
|
|
overflow: 'auto',
|
|
maxHeight: 'calc(100vh - 320px)',
|
|
paddingLeft: 10,
|
|
paddingRight: 10,
|
|
}}
|
|
>
|
|
<Row type="flex" justify="center">
|
|
<RadioGroup type="button" buttonSize="large" onChange={onChange} value={value}>
|
|
<Radio value={1}>cookie文件</Radio>
|
|
<Radio value={2}>扫码登录</Radio>
|
|
</RadioGroup>
|
|
</Row>
|
|
<Row>{panel}</Row>
|
|
</Modal>
|
|
</SideSheet>
|
|
)
|
|
}
|
|
|
|
export default UserList
|