mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix: repair user password reset and recreate flow
This commit is contained in:
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
|
||||
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
|
||||
@@ -307,7 +307,7 @@ vim docker-compose.yml
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
@@ -516,7 +516,7 @@ docker compose up -d
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -779,26 +779,26 @@ cd MediaStationGo
|
||||
|
||||
| 平台 | 包名示例 |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
|
||||
|
||||
部署步骤:
|
||||
|
||||
```bash
|
||||
# Linux 示例
|
||||
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.27-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.28-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.27-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.28-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+11
-11
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
|
||||
|
||||
```bash
|
||||
cat > .env <<'EOF'
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
MEDIASTATION_HTTP_PORT=18080
|
||||
MEDIASTATION_DATA_DIR=./data
|
||||
MEDIASTATION_CACHE_DIR=./cache
|
||||
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
|
||||
|
||||
| Platform | Package example |
|
||||
| --- | --- |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
|
||||
| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
|
||||
| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
|
||||
| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
|
||||
| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
|
||||
| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
|
||||
|
||||
Linux example:
|
||||
|
||||
```bash
|
||||
tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.27-linux-amd64
|
||||
tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
|
||||
cd MediaStationGo-v0.0.28-linux-amd64
|
||||
MEDIASTATION_APP_PORT=18080 ./mediastation-go
|
||||
```
|
||||
|
||||
Windows example:
|
||||
|
||||
```powershell
|
||||
Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.27-windows-amd64
|
||||
Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
|
||||
cd .\MediaStationGo-v0.0.28-windows-amd64
|
||||
$env:MEDIASTATION_APP_PORT = "18080"
|
||||
.\mediastation-go.exe
|
||||
```
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
#
|
||||
# 镜像版本:
|
||||
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
|
||||
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
|
||||
#
|
||||
# 路径映射总览:
|
||||
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
|
||||
|
||||
@@ -63,6 +63,10 @@ type adminUpdateUserReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type adminResetPasswordReq struct {
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminUpdateUserReq
|
||||
@@ -132,6 +136,21 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req adminResetPasswordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||
if err != nil || firstAdmin == nil {
|
||||
|
||||
@@ -280,6 +280,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
admin.GET("/users", listUsersHandler(svc))
|
||||
admin.POST("/users", createUserHandler(svc))
|
||||
admin.PATCH("/users/:id", updateUserHandler(svc))
|
||||
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
|
||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||
admin.GET("/settings", listSettingsHandler(svc))
|
||||
|
||||
@@ -81,6 +81,27 @@ func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
|
||||
return r.db.WithContext(ctx).Create(u).Error
|
||||
}
|
||||
|
||||
// ReleaseDeletedUsername renames soft-deleted rows that still hold a unique
|
||||
// username so the same account name can be created again.
|
||||
func (r *UserRepository) ReleaseDeletedUsername(ctx context.Context, username string) error {
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
released := username + "__deleted__" + time.Now().Format("20060102150405.000000000")
|
||||
if len(released) > 64 {
|
||||
sum := sha256.Sum256([]byte(released))
|
||||
released = username
|
||||
if len(released) > 43 {
|
||||
released = released[:43]
|
||||
}
|
||||
released += "__deleted__" + hex.EncodeToString(sum[:])[:10]
|
||||
}
|
||||
return r.db.WithContext(ctx).Unscoped().
|
||||
Model(&model.User{}).
|
||||
Where("username = ? AND deleted_at IS NOT NULL", username).
|
||||
Update("username", released).Error
|
||||
}
|
||||
|
||||
// FindByUsername returns the user matching username, or (nil, nil) when absent.
|
||||
func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
|
||||
var u model.User
|
||||
@@ -161,9 +182,31 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
|
||||
Update("last_login_at", &now).Error
|
||||
}
|
||||
|
||||
// Delete removes a user (soft-delete via gorm.DeletedAt).
|
||||
// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique
|
||||
// username, and drops Telegram bindings so future re-created users bind cleanly.
|
||||
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id).Error
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000")
|
||||
if len(released) > 64 {
|
||||
sum := sha256.Sum256([]byte(user.ID + user.Username))
|
||||
base := user.Username
|
||||
if len(base) > 43 {
|
||||
base = base[:43]
|
||||
}
|
||||
released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10]
|
||||
}
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.User{}, "id = ?", id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Library ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,6 +95,9 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
} else if existing != nil {
|
||||
return nil, nil, ErrUsernameTaken
|
||||
}
|
||||
if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n, err := s.repo.User.Count(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
} else if n >= LicensedMaxUsers(ctx, s.repo) {
|
||||
@@ -181,6 +184,29 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
|
||||
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
||||
}
|
||||
|
||||
// ResetPassword lets an administrator set a new password without knowing the
|
||||
// user's old password.
|
||||
func (s *AuthService) ResetPassword(ctx context.Context, userID, newPwd string) error {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return errors.New("missing user id")
|
||||
}
|
||||
if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
|
||||
return errors.New("new password must be at least 6 characters")
|
||||
}
|
||||
u, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u == nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
hash, err := hashPassword(newPwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
||||
}
|
||||
|
||||
// VerifyPassword checks a user's current password without mutating account
|
||||
// state. It is used for sensitive self-service actions such as hiding adult
|
||||
// libraries or deleting play profiles.
|
||||
|
||||
@@ -21,7 +21,7 @@ func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *Pr
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
@@ -66,6 +66,87 @@ func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, auth, _, _ := newAuthTestServices(t)
|
||||
user, _, err := auth.Register(ctx, "viewer", "old-password")
|
||||
if err != nil {
|
||||
t.Fatalf("register old user: %v", err)
|
||||
}
|
||||
if err := repos.DB.Create(&model.TelegramBinding{
|
||||
TelegramUserID: 10001,
|
||||
TelegramName: "@viewer",
|
||||
ChatID: 10001,
|
||||
UserID: user.ID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create telegram binding: %v", err)
|
||||
}
|
||||
if err := repos.User.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
|
||||
next, _, err := auth.Register(ctx, "viewer", "new-password")
|
||||
if err != nil {
|
||||
t.Fatalf("register same username after delete: %v", err)
|
||||
}
|
||||
if next.ID == user.ID {
|
||||
t.Fatal("recreated user should be a new account row")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
|
||||
t.Fatalf("login recreated user: %v", err)
|
||||
}
|
||||
var bindings int64
|
||||
if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil {
|
||||
t.Fatalf("count bindings: %v", err)
|
||||
}
|
||||
if bindings != 0 {
|
||||
t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos, auth, _, _ := newAuthTestServices(t)
|
||||
if err := repos.User.Create(ctx, &model.User{
|
||||
Username: "legacy",
|
||||
PasswordHash: "hash",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := repos.User.FindByUsername(ctx, "legacy")
|
||||
if err != nil || legacy == nil {
|
||||
t.Fatalf("find legacy user: %v", err)
|
||||
}
|
||||
if err := repos.DB.Delete(&model.User{}, "id = ?", legacy.ID).Error; err != nil {
|
||||
t.Fatalf("legacy soft delete: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := auth.Register(ctx, "legacy", "new-password"); err != nil {
|
||||
t.Fatalf("register should release old soft-deleted username: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, auth, _, _ := newAuthTestServices(t)
|
||||
user, _, err := auth.Register(ctx, "viewer", "old-password")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
|
||||
t.Fatalf("reset password: %v", err)
|
||||
}
|
||||
if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("old password should fail, got %v", err)
|
||||
}
|
||||
if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
|
||||
t.Fatalf("new password should login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
|
||||
perms := DefaultPermissions("user-1")
|
||||
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
|
||||
|
||||
@@ -231,14 +231,18 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage,
|
||||
if len(args) == 0 {
|
||||
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
|
||||
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
|
||||
if user == nil {
|
||||
_ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error
|
||||
return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n<code>/start 用户名 密码</code>"}
|
||||
}
|
||||
status := "未隐藏"
|
||||
if user != nil && user.HideAdult {
|
||||
if user.HideAdult {
|
||||
status = "已隐藏"
|
||||
}
|
||||
return telegramCommandReply{
|
||||
Text: fmt.Sprintf("<b>MediaStationGo 已绑定</b>\n\n你好 %s,当前账号:<b>%s</b>\n成人目录:<b>%s</b>", name, userNameOrFallback(user), status),
|
||||
Buttons: [][]telegramInlineButton{{{
|
||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user != nil && user.HideAdult],
|
||||
Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
|
||||
Data: "adult_toggle",
|
||||
}}},
|
||||
}
|
||||
@@ -895,6 +899,9 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
|
||||
TelegramUserID: int64(msg.From.ID),
|
||||
TelegramName: name,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
|
||||
repos, _, _, _ := newAuthTestServices(t)
|
||||
if err := repos.DB.Create(&model.TelegramBinding{
|
||||
TelegramUserID: 20001,
|
||||
TelegramName: "@viewer",
|
||||
ChatID: 20001,
|
||||
UserID: "deleted-user",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create binding: %v", err)
|
||||
}
|
||||
bot := NewTelegramBotService(zap.NewNop(), repos, nil)
|
||||
|
||||
reply := bot.cmdStart(t.Context(), &TelegramMessage{
|
||||
From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"},
|
||||
Chat: TelegramChat{ID: 20001, Type: "private"},
|
||||
}, nil)
|
||||
|
||||
if !strings.Contains(reply.Text, "已不存在") {
|
||||
t.Fatalf("expected stale binding message, got %q", reply.Text)
|
||||
}
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count binding: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("stale binding should be removed, got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export const adminAPI = {
|
||||
updateUser: (id: string, payload: { username: string }) =>
|
||||
api.patch<User>(`/admin/users/${id}`, payload).then((r) => r.data),
|
||||
|
||||
resetUserPassword: (id: string, password: string) =>
|
||||
api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data),
|
||||
|
||||
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
|
||||
|
||||
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
@@ -8,6 +8,7 @@ import type { Library, User } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
|
||||
@@ -206,6 +207,28 @@ function UsersPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (u: User) => {
|
||||
const nextPassword = await requestPassword({
|
||||
title: `重置 ${u.username} 的密码`,
|
||||
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
|
||||
confirmText: '重置密码',
|
||||
})
|
||||
if (!nextPassword) return
|
||||
if (nextPassword.length < 6) {
|
||||
toast.error('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminAPI.resetUserPassword(u.id, nextPassword)
|
||||
toast.success('密码已重置')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'重置密码失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
@@ -303,6 +326,13 @@ function UsersPanel() {
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
onClick={() => resetPassword(u)}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={u.is_protected}
|
||||
|
||||
Reference in New Issue
Block a user