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