feat(admin): bulk user management (#2997)
- Batch status/group update endpoint: POST /admin/user/batch/update, protects caller and reserved admin (id=1), clears ban fields on status change via inventory BatchUpdate - user_ids list condition for comma-separated UID filtering - last_login column on users, stamped at token issue (password, SSO, passkey), sortable in the admin user list - Admin UI: UID filter field, batch edit dialog, last-login column Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3582/head
parent
5b206ceecc
commit
18ed1e649b
File diff suppressed because one or more lines are too long
@ -0,0 +1,101 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, ListItemText, Stack } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { batchUpdateUser } from "../../../api/api";
|
||||
import { UserStatus } from "../../../api/dashboard";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import { DenseSelect } from "../../Common/StyledComponents";
|
||||
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
|
||||
import SettingForm from "../../Pages/Setting/SettingForm";
|
||||
import GroupSelectionInput from "../Common/GroupSelectionInput";
|
||||
|
||||
export interface BatchUserDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ids: number[];
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
const NoChange = " ";
|
||||
|
||||
const BatchUserDialog = ({ open, onClose, ids, onUpdated }: BatchUserDialogProps) => {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const dispatch = useAppDispatch();
|
||||
const [status, setStatus] = useState(NoChange);
|
||||
const [group, setGroup] = useState(NoChange);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStatus(NoChange);
|
||||
setGroup(NoChange);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onSubmit = () => {
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
batchUpdateUser({
|
||||
ids,
|
||||
status: status === NoChange ? undefined : (status as "active" | "inactive" | "manual_banned"),
|
||||
group_id: group === NoChange ? undefined : parseInt(group),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
onUpdated?.();
|
||||
onClose();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("user.batchEditXUsers", { num: ids.length })}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<SettingForm title={t("user.status")} noContainer lgWidth={12}>
|
||||
<FormControl fullWidth>
|
||||
<DenseSelect value={status} onChange={(e) => setStatus(e.target.value as string)}>
|
||||
<SquareMenuItem value={NoChange}>
|
||||
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
|
||||
<em>{t("user.noChange")}</em>
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
{Object.values(UserStatus)
|
||||
.filter((value) => value !== UserStatus.sys_banned)
|
||||
.map((value) => (
|
||||
<SquareMenuItem value={value} key={value}>
|
||||
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{t(`user.status_${value}`)}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</DenseSelect>
|
||||
</FormControl>
|
||||
</SettingForm>
|
||||
<SettingForm title={t("user.group")} noContainer lgWidth={12}>
|
||||
<GroupSelectionInput
|
||||
value={group}
|
||||
onChange={setGroup}
|
||||
emptyValue={NoChange}
|
||||
emptyText={t("user.noChange")}
|
||||
fullWidth
|
||||
/>
|
||||
</SettingForm>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("common:cancel")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={loading || ids.length === 0 || (status === NoChange && group === NoChange)}
|
||||
>
|
||||
{t("user.apply")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchUserDialog;
|
||||
@ -0,0 +1,97 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
entuser "github.com/cloudreve/Cloudreve/v4/ent/user"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBatchUpdateUsers(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
g1 := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
g2 := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
uc := NewUserClient(client)
|
||||
|
||||
u1 := client.User.Create().SetEmail("b1@example.com").SetNick("u1").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
|
||||
u2 := client.User.Create().SetEmail("b2@example.com").SetNick("u2").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
|
||||
u3 := client.User.Create().SetEmail("b3@example.com").SetNick("u3").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx)
|
||||
|
||||
t.Run("status change clears ban fields", func(t *testing.T) {
|
||||
banned := client.User.Create().SetEmail("banned@example.com").SetNick("ub").
|
||||
SetStatus(entuser.StatusManualBanned).SetBanExpires(time.Now().Add(time.Hour)).
|
||||
SetBanReason("spam").SetGroup(g1).SaveX(ctx)
|
||||
|
||||
st := entuser.StatusInactive
|
||||
n, err := uc.BatchUpdate(ctx, []int{banned.ID}, &st, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
|
||||
got := client.User.GetX(ctx, banned.ID)
|
||||
require.Equal(t, entuser.StatusInactive, got.Status)
|
||||
require.Nil(t, got.BanExpires)
|
||||
require.Equal(t, "", got.BanReason)
|
||||
})
|
||||
|
||||
t.Run("group change", func(t *testing.T) {
|
||||
n, err := uc.BatchUpdate(ctx, []int{u1.ID, u2.ID}, nil, g2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, n)
|
||||
|
||||
require.Equal(t, g2.ID, client.User.GetX(ctx, u1.ID).QueryGroup().OnlyIDX(ctx))
|
||||
require.Equal(t, g2.ID, client.User.GetX(ctx, u2.ID).QueryGroup().OnlyIDX(ctx))
|
||||
require.Equal(t, g1.ID, client.User.GetX(ctx, u3.ID).QueryGroup().OnlyIDX(ctx))
|
||||
})
|
||||
|
||||
t.Run("empty id list is no-op", func(t *testing.T) {
|
||||
st := entuser.StatusInactive
|
||||
n, err := uc.BatchUpdate(ctx, nil, &st, g2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListUsersByIDs(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
uc := NewUserClient(client)
|
||||
|
||||
u1 := client.User.Create().SetEmail("id1@example.com").SetNick("u1").SetGroup(group).SaveX(ctx)
|
||||
u2 := client.User.Create().SetEmail("id2@example.com").SetNick("u2").SetGroup(group).SaveX(ctx)
|
||||
client.User.Create().SetEmail("id3@example.com").SetNick("u3").SetGroup(group).SaveX(ctx)
|
||||
|
||||
res, err := uc.ListUsers(ctx, &ListUserParameters{
|
||||
PaginationArgs: &PaginationArgs{Page: 0, PageSize: 10},
|
||||
IDs: []int{u1.ID, u2.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, res.PaginationResults.TotalItems)
|
||||
require.Len(t, res.Users, 2)
|
||||
}
|
||||
|
||||
func TestUpdateLastLogin(t *testing.T) {
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
uc := NewUserClient(client)
|
||||
|
||||
u := client.User.Create().SetEmail("ll@example.com").SetNick("u").SetGroup(group).SaveX(ctx)
|
||||
require.Nil(t, client.User.GetX(ctx, u.ID).LastLogin)
|
||||
|
||||
require.NoError(t, uc.UpdateLastLogin(ctx, u.ID))
|
||||
got := client.User.GetX(ctx, u.ID)
|
||||
require.NotNil(t, got.LastLogin)
|
||||
require.WithinDuration(t, time.Now(), *got.LastLogin, time.Minute)
|
||||
}
|
||||
Loading…
Reference in new issue