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
Tomas Dvorak 2 weeks ago
parent 5b206ceecc
commit 18ed1e649b

File diff suppressed because one or more lines are too long

@ -485,6 +485,7 @@ var (
{Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"},
{Name: "ban_expires", Type: field.TypeTime, Nullable: true},
{Name: "ban_reason", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "last_login", Type: field.TypeTime, Nullable: true},
{Name: "storage", Type: field.TypeInt64, Default: 0},
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "avatar", Type: field.TypeString, Nullable: true},
@ -499,7 +500,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[14]},
Columns: []*schema.Column{UsersColumns[15]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -15174,6 +15174,7 @@ type UserMutation struct {
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
two_factor_secret *string
@ -15685,6 +15686,55 @@ func (m *UserMutation) ResetBanReason() {
delete(m.clearedFields, user.FieldBanReason)
}
// SetLastLogin sets the "last_login" field.
func (m *UserMutation) SetLastLogin(t time.Time) {
m.last_login = &t
}
// LastLogin returns the value of the "last_login" field in the mutation.
func (m *UserMutation) LastLogin() (r time.Time, exists bool) {
v := m.last_login
if v == nil {
return
}
return *v, true
}
// OldLastLogin returns the old "last_login" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldLastLogin(ctx context.Context) (v *time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldLastLogin is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldLastLogin requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldLastLogin: %w", err)
}
return oldValue.LastLogin, nil
}
// ClearLastLogin clears the value of the "last_login" field.
func (m *UserMutation) ClearLastLogin() {
m.last_login = nil
m.clearedFields[user.FieldLastLogin] = struct{}{}
}
// LastLoginCleared returns if the "last_login" field was cleared in this mutation.
func (m *UserMutation) LastLoginCleared() bool {
_, ok := m.clearedFields[user.FieldLastLogin]
return ok
}
// ResetLastLogin resets all changes to the "last_login" field.
func (m *UserMutation) ResetLastLogin() {
m.last_login = nil
delete(m.clearedFields, user.FieldLastLogin)
}
// SetStorage sets the "storage" field.
func (m *UserMutation) SetStorage(i int64) {
m.storage = &i
@ -16430,7 +16480,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 14)
fields := make([]string, 0, 15)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -16458,6 +16508,9 @@ func (m *UserMutation) Fields() []string {
if m.ban_reason != nil {
fields = append(fields, user.FieldBanReason)
}
if m.last_login != nil {
fields = append(fields, user.FieldLastLogin)
}
if m.storage != nil {
fields = append(fields, user.FieldStorage)
}
@ -16499,6 +16552,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.BanExpires()
case user.FieldBanReason:
return m.BanReason()
case user.FieldLastLogin:
return m.LastLogin()
case user.FieldStorage:
return m.Storage()
case user.FieldTwoFactorSecret:
@ -16536,6 +16591,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldBanExpires(ctx)
case user.FieldBanReason:
return m.OldBanReason(ctx)
case user.FieldLastLogin:
return m.OldLastLogin(ctx)
case user.FieldStorage:
return m.OldStorage(ctx)
case user.FieldTwoFactorSecret:
@ -16618,6 +16675,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetBanReason(v)
return nil
case user.FieldLastLogin:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetLastLogin(v)
return nil
case user.FieldStorage:
v, ok := value.(int64)
if !ok {
@ -16710,6 +16774,9 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldBanReason) {
fields = append(fields, user.FieldBanReason)
}
if m.FieldCleared(user.FieldLastLogin) {
fields = append(fields, user.FieldLastLogin)
}
if m.FieldCleared(user.FieldTwoFactorSecret) {
fields = append(fields, user.FieldTwoFactorSecret)
}
@ -16745,6 +16812,9 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldBanReason:
m.ClearBanReason()
return nil
case user.FieldLastLogin:
m.ClearLastLogin()
return nil
case user.FieldTwoFactorSecret:
m.ClearTwoFactorSecret()
return nil
@ -16789,6 +16859,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldBanReason:
m.ResetBanReason()
return nil
case user.FieldLastLogin:
m.ResetLastLogin()
return nil
case user.FieldStorage:
m.ResetStorage()
return nil

@ -415,11 +415,11 @@ func init() {
// user.NickValidator is a validator for the "nick" field. It is called by the builders before save.
user.NickValidator = userDescNick.Validators[0].(func(string) error)
// userDescStorage is the schema descriptor for storage field.
userDescStorage := userFields[6].Descriptor()
userDescStorage := userFields[7].Descriptor()
// user.DefaultStorage holds the default value on creation for the storage field.
user.DefaultStorage = userDescStorage.Default.(int64)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[9].Descriptor()
userDescSettings := userFields[10].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
}

@ -32,6 +32,10 @@ func (User) Fields() []ent.Field {
// ban_reason is shown to the user when a banned login is rejected.
field.Text("ban_reason").
Optional(),
field.Time("last_login").
Optional().
Nillable().
Comment("Time of the last successful sign-in"),
field.Int64("storage").
Default(0),
field.String("two_factor_secret").

@ -38,6 +38,8 @@ type User struct {
BanExpires *time.Time `json:"ban_expires,omitempty"`
// BanReason holds the value of the "ban_reason" field.
BanReason string `json:"ban_reason,omitempty"`
// Time of the last successful sign-in
LastLogin *time.Time `json:"last_login,omitempty"`
// Storage holds the value of the "storage" field.
Storage int64 `json:"storage,omitempty"`
// TwoFactorSecret holds the value of the "two_factor_secret" field.
@ -175,7 +177,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldAvatar:
values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires:
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
@ -254,6 +256,13 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.BanReason = value.String
}
case user.FieldLastLogin:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field last_login", values[i])
} else if value.Valid {
u.LastLogin = new(time.Time)
*u.LastLogin = value.Time
}
case user.FieldStorage:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field storage", values[i])
@ -397,6 +406,11 @@ func (u *User) String() string {
builder.WriteString("ban_reason=")
builder.WriteString(u.BanReason)
builder.WriteString(", ")
if v := u.LastLogin; v != nil {
builder.WriteString("last_login=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("storage=")
builder.WriteString(fmt.Sprintf("%v", u.Storage))
builder.WriteString(", ")

@ -35,6 +35,8 @@ const (
FieldBanExpires = "ban_expires"
// FieldBanReason holds the string denoting the ban_reason field in the database.
FieldBanReason = "ban_reason"
// FieldLastLogin holds the string denoting the last_login field in the database.
FieldLastLogin = "last_login"
// FieldStorage holds the string denoting the storage field in the database.
FieldStorage = "storage"
// FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database.
@ -142,6 +144,7 @@ var Columns = []string{
FieldStatus,
FieldBanExpires,
FieldBanReason,
FieldLastLogin,
FieldStorage,
FieldTwoFactorSecret,
FieldAvatar,
@ -264,6 +267,11 @@ func ByBanReason(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBanReason, opts...).ToFunc()
}
// ByLastLogin orders the results by the last_login field.
func ByLastLogin(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastLogin, opts...).ToFunc()
}
// ByStorage orders the results by the storage field.
func ByStorage(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStorage, opts...).ToFunc()

@ -95,6 +95,11 @@ func BanReason(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanReason, v))
}
// LastLogin applies equality check predicate on the "last_login" field. It's identical to LastLoginEQ.
func LastLogin(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastLogin, v))
}
// Storage applies equality check predicate on the "storage" field. It's identical to StorageEQ.
func Storage(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))
@ -595,6 +600,56 @@ func BanReasonContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldBanReason, v))
}
// LastLoginEQ applies the EQ predicate on the "last_login" field.
func LastLoginEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastLogin, v))
}
// LastLoginNEQ applies the NEQ predicate on the "last_login" field.
func LastLoginNEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldNEQ(FieldLastLogin, v))
}
// LastLoginIn applies the In predicate on the "last_login" field.
func LastLoginIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldIn(FieldLastLogin, vs...))
}
// LastLoginNotIn applies the NotIn predicate on the "last_login" field.
func LastLoginNotIn(vs ...time.Time) predicate.User {
return predicate.User(sql.FieldNotIn(FieldLastLogin, vs...))
}
// LastLoginGT applies the GT predicate on the "last_login" field.
func LastLoginGT(v time.Time) predicate.User {
return predicate.User(sql.FieldGT(FieldLastLogin, v))
}
// LastLoginGTE applies the GTE predicate on the "last_login" field.
func LastLoginGTE(v time.Time) predicate.User {
return predicate.User(sql.FieldGTE(FieldLastLogin, v))
}
// LastLoginLT applies the LT predicate on the "last_login" field.
func LastLoginLT(v time.Time) predicate.User {
return predicate.User(sql.FieldLT(FieldLastLogin, v))
}
// LastLoginLTE applies the LTE predicate on the "last_login" field.
func LastLoginLTE(v time.Time) predicate.User {
return predicate.User(sql.FieldLTE(FieldLastLogin, v))
}
// LastLoginIsNil applies the IsNil predicate on the "last_login" field.
func LastLoginIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldLastLogin))
}
// LastLoginNotNil applies the NotNil predicate on the "last_login" field.
func LastLoginNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldLastLogin))
}
// StorageEQ applies the EQ predicate on the "storage" field.
func StorageEQ(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))

@ -142,6 +142,20 @@ func (uc *UserCreate) SetNillableBanReason(s *string) *UserCreate {
return uc
}
// SetLastLogin sets the "last_login" field.
func (uc *UserCreate) SetLastLogin(t time.Time) *UserCreate {
uc.mutation.SetLastLogin(t)
return uc
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uc *UserCreate) SetNillableLastLogin(t *time.Time) *UserCreate {
if t != nil {
uc.SetLastLogin(*t)
}
return uc
}
// SetStorage sets the "storage" field.
func (uc *UserCreate) SetStorage(i int64) *UserCreate {
uc.mutation.SetStorage(i)
@ -504,6 +518,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
_node.BanReason = value
}
if value, ok := uc.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
_node.LastLogin = &value
}
if value, ok := uc.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
_node.Storage = value
@ -837,6 +855,24 @@ func (u *UserUpsert) ClearBanReason() *UserUpsert {
return u
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsert) SetLastLogin(v time.Time) *UserUpsert {
u.Set(user.FieldLastLogin, v)
return u
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsert) UpdateLastLogin() *UserUpsert {
u.SetExcluded(user.FieldLastLogin)
return u
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsert) ClearLastLogin() *UserUpsert {
u.SetNull(user.FieldLastLogin)
return u
}
// SetStorage sets the "storage" field.
func (u *UserUpsert) SetStorage(v int64) *UserUpsert {
u.Set(user.FieldStorage, v)
@ -1106,6 +1142,27 @@ func (u *UserUpsertOne) ClearBanReason() *UserUpsertOne {
})
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsertOne) SetLastLogin(v time.Time) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetLastLogin(v)
})
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateLastLogin() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateLastLogin()
})
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsertOne) ClearLastLogin() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearLastLogin()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertOne) SetStorage(v int64) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -1560,6 +1617,27 @@ func (u *UserUpsertBulk) ClearBanReason() *UserUpsertBulk {
})
}
// SetLastLogin sets the "last_login" field.
func (u *UserUpsertBulk) SetLastLogin(v time.Time) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetLastLogin(v)
})
}
// UpdateLastLogin sets the "last_login" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateLastLogin() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateLastLogin()
})
}
// ClearLastLogin clears the value of the "last_login" field.
func (u *UserUpsertBulk) ClearLastLogin() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearLastLogin()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertBulk) SetStorage(v int64) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -166,6 +166,26 @@ func (uu *UserUpdate) ClearBanReason() *UserUpdate {
return uu
}
// SetLastLogin sets the "last_login" field.
func (uu *UserUpdate) SetLastLogin(t time.Time) *UserUpdate {
uu.mutation.SetLastLogin(t)
return uu
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uu *UserUpdate) SetNillableLastLogin(t *time.Time) *UserUpdate {
if t != nil {
uu.SetLastLogin(*t)
}
return uu
}
// ClearLastLogin clears the value of the "last_login" field.
func (uu *UserUpdate) ClearLastLogin() *UserUpdate {
uu.mutation.ClearLastLogin()
return uu
}
// SetStorage sets the "storage" field.
func (uu *UserUpdate) SetStorage(i int64) *UserUpdate {
uu.mutation.ResetStorage()
@ -676,6 +696,12 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if uu.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uu.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
}
if uu.mutation.LastLoginCleared() {
_spec.ClearField(user.FieldLastLogin, field.TypeTime)
}
if value, ok := uu.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}
@ -1237,6 +1263,26 @@ func (uuo *UserUpdateOne) ClearBanReason() *UserUpdateOne {
return uuo
}
// SetLastLogin sets the "last_login" field.
func (uuo *UserUpdateOne) SetLastLogin(t time.Time) *UserUpdateOne {
uuo.mutation.SetLastLogin(t)
return uuo
}
// SetNillableLastLogin sets the "last_login" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableLastLogin(t *time.Time) *UserUpdateOne {
if t != nil {
uuo.SetLastLogin(*t)
}
return uuo
}
// ClearLastLogin clears the value of the "last_login" field.
func (uuo *UserUpdateOne) ClearLastLogin() *UserUpdateOne {
uuo.mutation.ClearLastLogin()
return uuo
}
// SetStorage sets the "storage" field.
func (uuo *UserUpdateOne) SetStorage(i int64) *UserUpdateOne {
uuo.mutation.ResetStorage()
@ -1777,6 +1823,12 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if uuo.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uuo.mutation.LastLogin(); ok {
_spec.SetField(user.FieldLastLogin, field.TypeTime, value)
}
if uuo.mutation.LastLoginCleared() {
_spec.ClearField(user.FieldLastLogin, field.TypeTime)
}
if value, ok := uuo.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}

@ -1392,7 +1392,13 @@
"banExpires": "Ban expires",
"banExpiresDes": "Optional. The ban lifts automatically after this time; empty means permanent.",
"banReason": "Ban reason",
"banReasonDes": "Optional. Shown to the user when a banned login is rejected."
"banReasonDes": "Optional. Shown to the user when a banned login is rejected.",
"lastLogin": "Last login",
"uids": "User IDs",
"uidsPlaceholder": "Comma-separated IDs, e.g. 2,3,5",
"editXUsers": "Edit {{num}} users",
"batchEditXUsers": "Batch edit {{num}} users",
"noChange": "No change"
},
"file": {
"deleteXFiles": "Delete {{num}} files",

@ -1392,7 +1392,13 @@
"banExpires": "封禁截止时间",
"banExpiresDes": "可选。到期后自动解除封禁;留空表示永久封禁。",
"banReason": "封禁原因",
"banReasonDes": "可选。封禁用户登录被拒绝时向其展示。"
"banReasonDes": "可选。封禁用户登录被拒绝时向其展示。",
"lastLogin": "上次登录",
"uids": "用户 ID",
"uidsPlaceholder": "逗号分隔的 ID,如 2,3,5",
"editXUsers": "编辑 {{num}} 个用户",
"batchEditXUsers": "批量编辑 {{num}} 个用户",
"noChange": "不修改"
},
"file": {
"deleteXFiles": "删除 {{num}} 个文件",

@ -1862,6 +1862,26 @@ export function batchDeleteUser(args: BatchIDService): ThunkResponse<void> {
};
}
export interface BatchUserUpdateService {
ids: number[];
status?: "active" | "inactive" | "manual_banned";
group_id?: number;
}
export function batchUpdateUser(args: BatchUserUpdateService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/admin/user/batch/update`,
{ method: "POST", data: args },
{
...defaultOpts,
},
),
);
};
}
export function getFlattenFileList(args: AdminListService): ThunkResponse<ListFileResponse> {
return async (dispatch, _getState) => {
return await dispatch(

@ -266,6 +266,7 @@ export interface User extends CommonMixin {
group_expires?: string;
ban_expires?: string;
ban_reason?: string;
last_login?: string;
notify_date?: string;
group_users?: number;
previous_group?: number;

@ -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;

@ -16,6 +16,8 @@ export interface UserFilterPopoverProps extends PopoverProps {
setGroup: (group: string) => void;
status: string;
setStatus: (status: string) => void;
uids: string;
setUids: (uids: string) => void;
clearFilters: () => void;
}
@ -28,6 +30,8 @@ const UserFilterPopover = ({
setGroup,
status,
setStatus,
uids,
setUids,
clearFilters,
onClose,
open,
@ -40,6 +44,7 @@ const UserFilterPopover = ({
const [localNick, setLocalNick] = useState(nick);
const [localGroup, setLocalGroup] = useState(group);
const [localStatus, setLocalStatus] = useState(status);
const [localUids, setLocalUids] = useState(uids);
// Initialize local state when popup opens
useEffect(() => {
@ -48,6 +53,7 @@ const UserFilterPopover = ({
setLocalNick(nick);
setLocalGroup(group);
setLocalStatus(status);
setLocalUids(uids);
}
}, [open]);
@ -57,6 +63,7 @@ const UserFilterPopover = ({
setNick(localNick);
setGroup(localGroup == " " ? "" : localGroup);
setStatus(localStatus == " " ? "" : localStatus);
setUids(localUids);
onClose?.({}, "backdropClick");
};
@ -66,6 +73,7 @@ const UserFilterPopover = ({
setLocalNick("");
setLocalGroup("");
setLocalStatus("");
setLocalUids("");
clearFilters();
onClose?.({}, "backdropClick");
};
@ -114,6 +122,16 @@ const UserFilterPopover = ({
/>
</SettingForm>
<SettingForm title={t("user.uids")} noContainer lgWidth={12}>
<DenseFilledTextField
fullWidth
value={localUids}
onChange={(e) => setLocalUids(e.target.value)}
placeholder={t("user.uidsPlaceholder")}
size="small"
/>
</SettingForm>
<SettingForm title={t("user.group")} noContainer lgWidth={12}>
<GroupSelectionInput
value={localGroup == "" ? " " : localGroup}

@ -8,6 +8,7 @@ import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { sizeToString } from "../../../util";
import { NoWrapTableCell, NoWrapTypography, SquareChip } from "../../Common/StyledComponents";
import TimeBadge from "../../Common/TimeBadge";
import UserAvatar from "../../Common/User/UserAvatar";
import Delete from "../../Icons/Delete";
import PersonPasskey from "../../Icons/PersonPasskey";
@ -99,6 +100,9 @@ const UserRow = ({ user, loading, deleting, selected, onDelete, onDetails, onSel
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
</TableRow>
);
}
@ -164,6 +168,15 @@ const UserRow = ({ user, loading, deleting, selected, onDelete, onDetails, onSel
</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>{sizeToString(user?.storage ?? 0)}</NoWrapTableCell>
<NoWrapTableCell>
{user?.last_login ? (
<TimeBadge datetime={user.last_login} variant="inherit" />
) : (
<NoWrapTypography variant="inherit" color="text.secondary">
-
</NoWrapTypography>
)}
</NoWrapTableCell>
<NoWrapTableCell>
<IconButton size="small" onClick={onDeleteClick} disabled={deleteLoading || deleting}>
<Delete fontSize="small" />

@ -1,4 +1,4 @@
import { Delete } from "@mui/icons-material";
import { Delete, Edit } from "@mui/icons-material";
import {
Badge,
Box,
@ -33,6 +33,7 @@ import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
import TablePagination from "../Common/TablePagination";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import BatchUserDialog from "./BatchUserDialog";
import NewUserDialog from "./NewUserDialog";
import UserDialog from "./UserDialog/UserDialog";
import UserFilterPopover from "./UserFilterPopover";
@ -41,6 +42,7 @@ export const EmailQuery = "email";
export const NickQuery = "nick";
export const GroupQuery = "group";
export const StatusQuery = "status";
export const UidsQuery = "uids";
const UserSetting = () => {
const { t } = useTranslation("dashboard");
@ -61,9 +63,11 @@ const UserSetting = () => {
const [nick, setNick] = useQueryState(NickQuery, { defaultValue: "" });
const [group, setGroup] = useQueryState(GroupQuery, { defaultValue: "" });
const [status, setStatus] = useQueryState(StatusQuery, { defaultValue: "" });
const [uids, setUids] = useQueryState(UidsQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const [createNewOpen, setCreateNewOpen] = useState(false);
const [batchEditOpen, setBatchEditOpen] = useState(false);
const filterPopupState = usePopupState({
variant: "popover",
popupId: "userFilterPopover",
@ -81,11 +85,12 @@ const UserSetting = () => {
setNick("");
setGroup("");
setStatus("");
}, [setEmail, setNick, setGroup, setStatus]);
setUids("");
}, [setEmail, setNick, setGroup, setStatus, setUids]);
useEffect(() => {
fetchUsers();
}, [page, pageSize, orderBy, orderDirection, email, nick, group, status]);
}, [page, pageSize, orderBy, orderDirection, email, nick, group, status, uids]);
const fetchUsers = () => {
setLoading(true);
@ -101,6 +106,7 @@ const UserSetting = () => {
user_nick: nick,
user_group: group,
user_status: status,
user_ids: uids,
},
}),
)
@ -169,8 +175,8 @@ const UserSetting = () => {
};
const hasActiveFilters = useMemo(() => {
return !!(email || nick || group || status);
}, [email, nick, group, status]);
return !!(email || nick || group || status || uids);
}, [email, nick, group, status, uids]);
const handleUserDialogOpen = (id: number) => {
setUserDialogID(id);
@ -193,6 +199,12 @@ const UserSetting = () => {
userID={userDialogID}
onUpdated={() => fetchUsers()}
/>
<BatchUserDialog
open={batchEditOpen}
onClose={() => setBatchEditOpen(false)}
ids={Array.from(selected)}
onUpdated={() => fetchUsers()}
/>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.users")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
@ -210,6 +222,8 @@ const UserSetting = () => {
setGroup={setGroup}
status={status}
setStatus={setStatus}
uids={uids}
setUids={setUids}
clearFilters={clearFilters}
/>
@ -226,6 +240,9 @@ const UserSetting = () => {
{selected.length > 0 && !isMobile && (
<>
<Divider orientation="vertical" flexItem />
<Button startIcon={<Edit />} variant="contained" onClick={() => setBatchEditOpen(true)}>
{t("user.editXUsers", { num: selected.length })}
</Button>
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("user.deleteXUsers", { num: selected.length })}
</Button>
@ -234,6 +251,9 @@ const UserSetting = () => {
</Stack>
{isMobile && selected.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button startIcon={<Edit />} variant="contained" onClick={() => setBatchEditOpen(true)}>
{t("user.editXUsers", { num: selected.length })}
</Button>
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("user.deleteXUsers", { num: selected.length })}
</Button>
@ -274,6 +294,15 @@ const UserSetting = () => {
{t("user.usedStorage")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={150}>
<TableSortLabel
active={orderBy === "last_login"}
direction={direction}
onClick={onSortClick("last_login")}
>
{t("user.lastLogin")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>

@ -107,6 +107,10 @@ type (
ListUsers(ctx context.Context, args *ListUserParameters) (*ListUserResult, error)
// Upsert upserts a user.
Upsert(ctx context.Context, u *ent.User, password, twoFa string) (*ent.User, error)
// UpdateLastLogin stamps the user's last successful sign-in time.
UpdateLastLogin(ctx context.Context, uid int) error
// BatchUpdate applies a status and/or group change to the given users.
BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error)
// Delete deletes a user.
Delete(ctx context.Context, uid int) error
// CalculateStorage calculate user's storage from scratch and update user's storage.
@ -118,6 +122,7 @@ type (
Status user.Status
Nick string
Email string
IDs []int
}
ListUserResult struct {
*PaginationResults
@ -367,6 +372,28 @@ func (c *userClient) SetStatus(ctx context.Context, u *ent.User, status user.Sta
return c.client.User.UpdateOne(u).SetStatus(status).Save(ctx)
}
func (c *userClient) UpdateLastLogin(ctx context.Context, uid int) error {
return c.client.User.UpdateOneID(uid).SetLastLogin(time.Now()).Exec(ctx)
}
// BatchUpdate applies a status and/or group change to the given user IDs and
// returns the number of updated rows. Ban lifts/expiry fields are cleared when
// a user is (re)activated or banned through this path.
func (c *userClient) BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error) {
if len(ids) == 0 {
return 0, nil
}
stm := c.client.User.Update().Where(user.IDIn(ids...))
if status != nil {
stm.SetStatus(*status).ClearBanExpires().ClearBanReason()
}
if groupID > 0 {
stm.SetGroupUsers(groupID)
}
return stm.Save(ctx)
}
func (c *userClient) LiftExpiredBan(ctx context.Context, u *ent.User) (*ent.User, error) {
banned := u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned
if !banned || u.BanExpires == nil || u.BanExpires.After(time.Now()) {
@ -548,6 +575,9 @@ func (c *userClient) ListUsers(ctx context.Context, args *ListUserParameters) (*
if args.Email != "" {
query = query.Where(user.EmailContainsFold(args.Email))
}
if len(args.IDs) > 0 {
query = query.Where(user.IDIn(args.IDs...))
}
query.Order(getUserOrderOption(args)...)
// Count total items
@ -634,6 +664,8 @@ func getUserOrderOption(args *ListUserParameters) []user.OrderOption {
return []user.OrderOption{user.ByEmail(orderTerm), user.ByID(orderTerm)}
case user.FieldUpdatedAt:
return []user.OrderOption{user.ByUpdatedAt(orderTerm), user.ByID(orderTerm)}
case user.FieldLastLogin:
return []user.OrderOption{user.ByLastLogin(orderTerm), user.ByID(orderTerm)}
default:
return []user.OrderOption{user.ByID(orderTerm)}
}

@ -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)
}

@ -427,6 +427,17 @@ func AdminDeleteUser(c *gin.Context) {
c.JSON(200, serializer.Response{})
}
// AdminBatchUpdateUser 批量更新用户
func AdminBatchUpdateUser(c *gin.Context) {
service := ParametersFromContext[*admin.BatchUserUpdateService](c, admin.BatchUserUpdateParamCtx{})
err := service.Update(c)
if err != nil {
c.JSON(200, serializer.Err(c, err))
return
}
c.JSON(200, serializer.Response{})
}
func AdminListFiles(c *gin.Context) {
service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{})
res, err := service.Files(c)

@ -1200,6 +1200,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}),
controllers.AdminDeleteUser,
)
// 批量更新用户
batch.POST("update",
middleware.RequiredScopes(types.ScopeAdminWrite),
controllers.FromJSON[adminsvc.BatchUserUpdateService](adminsvc.BatchUserUpdateParamCtx{}),
controllers.AdminBatchUpdateUser,
)
}
user.POST(":id/calibrate",
middleware.RequiredScopes(types.ScopeAdminWrite),

@ -3,6 +3,7 @@ package admin
import (
"context"
"strconv"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
@ -38,6 +39,8 @@ const (
userGroupCondition = "user_group"
userNickCondition = "user_nick"
userEmailCondition = "user_email"
// userIDsCondition filters by a comma-separated list of numeric user IDs.
userIDsCondition = "user_ids"
)
func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error) {
@ -51,6 +54,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
var (
err error
groupID int
ids []int
)
if service.Conditions[userGroupCondition] != "" {
groupID, err = strconv.Atoi(service.Conditions[userGroupCondition])
@ -59,6 +63,20 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
}
}
if service.Conditions[userIDsCondition] != "" {
for _, part := range strings.Split(service.Conditions[userIDsCondition], ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.Atoi(part)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user ID list", err)
}
ids = append(ids, id)
}
}
res, err := userClient.ListUsers(ctx, &inventory.ListUserParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
@ -70,6 +88,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error
GroupID: groupID,
Nick: service.Conditions[userNickCondition],
Email: service.Conditions[userEmailCondition],
IDs: ids,
})
if err != nil {
@ -271,3 +290,46 @@ func (s *BatchUserService) Delete(c *gin.Context) error {
return ae.Aggregate()
}
type (
// BatchUserUpdateService applies a status and/or group change to a set of
// users. At least one of the two fields must be set.
BatchUserUpdateService struct {
IDs []int `json:"ids" binding:"min=1"`
Status string `json:"status" binding:"omitempty,oneof=active inactive manual_banned"`
GroupID int `json:"group_id" binding:"omitempty,min=1"`
}
BatchUserUpdateParamCtx struct{}
)
func (s *BatchUserUpdateService) Update(c *gin.Context) error {
if s.Status == "" && s.GroupID == 0 {
return serializer.NewError(serializer.CodeParamErr, "Nothing to update", nil)
}
dep := dependency.FromContext(c)
userClient := dep.UserClient()
current := inventory.UserFromContext(c)
// The caller and the reserved initial admin cannot be modified in bulk.
ae := serializer.NewAggregateError()
ids := lo.Filter(s.IDs, func(id int, _ int) bool {
if id == current.ID || id == 1 {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil))
return false
}
return true
})
var status *user.Status
if s.Status != "" {
st := user.Status(s.Status)
status = &st
}
if _, err := userClient.BatchUpdate(c, ids, status, s.GroupID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update users", err)
}
return ae.Aggregate()
}

@ -170,6 +170,12 @@ type (
func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
// Best-effort last-login stamp; a failed update must not block sign-in.
if err := dep.UserClient().UpdateLastLogin(c, u.ID); err != nil {
dep.Logger().Warning("Failed to update last_login for user %d: %s", u.ID, err)
}
token, err := dep.TokenAuth().Issue(c, &auth.IssueTokenArgs{
User: u,
RootTokenID: nil,

Loading…
Cancel
Save