完善登录账号自助修改密码
This commit is contained in:
parent
01dfda888a
commit
ac27c2dad0
@ -74,6 +74,34 @@ func (s *Server) requireAdmin(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Server) requireAuthenticated(c *gin.Context) {
|
||||
s.configMu.RLock()
|
||||
setupReady := s.config.SetupReady
|
||||
s.configMu.RUnlock()
|
||||
if !setupReady {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
|
||||
"error": "请先完成初始化配置",
|
||||
"request_id": requestIDFromContext(c),
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := s.currentAuthSession(c); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "请先登录管理后台",
|
||||
"request_id": requestIDFromContext(c),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !s.sameOriginForAPI(c.Request) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "请求来源校验失败",
|
||||
"request_id": requestIDFromContext(c),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func (s *Server) sameOriginForAPI(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
|
||||
142
backend/internal/httpapi/password_api.go
Normal file
142
backend/internal/httpapi/password_api.go
Normal file
@ -0,0 +1,142 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/auth"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/passwordpolicy"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
}
|
||||
|
||||
// changePassword 允许当前登录账号修改自己的密码,并在成功后使所有旧会话失效。
|
||||
func (s *Server) changePassword(c *gin.Context) {
|
||||
request := changePasswordRequest{}
|
||||
if err := bindJSON(c, &request, authRequestBodyLimit); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, "修改密码请求参数无效", err)
|
||||
return
|
||||
}
|
||||
if request.CurrentPassword == "" {
|
||||
s.writeError(c, http.StatusBadRequest, "请输入当前密码", nil)
|
||||
return
|
||||
}
|
||||
if request.NewPassword != request.ConfirmPassword {
|
||||
s.writeError(c, http.StatusBadRequest, "两次输入的新密码不一致", nil)
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.currentAuthSession(c)
|
||||
if !ok {
|
||||
s.writeError(c, http.StatusUnauthorized, "登录状态已失效,请重新登录", nil)
|
||||
return
|
||||
}
|
||||
s.configMu.RLock()
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
|
||||
if session.UserID == "primary-super-admin" {
|
||||
s.changePrimarySuperAdminPassword(c, session, cfg, request)
|
||||
return
|
||||
}
|
||||
s.changeDatabaseUserPassword(c, session, request)
|
||||
}
|
||||
|
||||
func (s *Server) changePrimarySuperAdminPassword(c *gin.Context, session authSession, cfg config.Config, request changePasswordRequest) {
|
||||
if _, err := auth.AuthenticateAdmin(session.Username, request.CurrentPassword, cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.PasswordHash); err != nil {
|
||||
s.writeError(c, http.StatusUnauthorized, "当前密码错误", nil)
|
||||
return
|
||||
}
|
||||
if err := passwordpolicy.Validate(request.NewPassword, cfg.Admin.Username); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.configPath) == "" {
|
||||
s.writeError(c, http.StatusInternalServerError, "服务配置路径未就绪,无法保存新密码", errors.New("配置路径未配置"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "生成新密码摘要失败", err)
|
||||
return
|
||||
}
|
||||
persistedConfig, err := config.Load(s.configPath)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "读取服务配置失败,无法保存新密码", err)
|
||||
return
|
||||
}
|
||||
previousConfig := persistedConfig
|
||||
persistedConfig.Admin.PasswordHash = string(hash)
|
||||
if err := s.syncPrimarySuperAdmin(c.Request.Context(), persistedConfig); err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "同步超级管理员账户失败", err)
|
||||
return
|
||||
}
|
||||
if err := persistedConfig.Save(s.configPath); err != nil {
|
||||
if restoreErr := s.syncPrimarySuperAdmin(c.Request.Context(), previousConfig); restoreErr != nil && s.logger != nil {
|
||||
s.logger.Error("保存新密码失败后恢复超级管理员摘要失败", "error", restoreErr)
|
||||
}
|
||||
s.writeError(c, http.StatusInternalServerError, "保存新密码失败", err)
|
||||
return
|
||||
}
|
||||
cfg.Admin.PasswordHash = string(hash)
|
||||
s.configMu.Lock()
|
||||
s.config = cfg
|
||||
s.configMu.Unlock()
|
||||
s.finishPasswordChange(c, session.UserID, "超级管理员密码修改成功,请使用新密码重新登录")
|
||||
}
|
||||
|
||||
func (s *Server) changeDatabaseUserPassword(c *gin.Context, session authSession, request changePasswordRequest) {
|
||||
if s.users == nil {
|
||||
s.writeError(c, http.StatusNotImplemented, "用户仓储尚未配置", errors.New("用户仓储未配置"))
|
||||
return
|
||||
}
|
||||
user, err := s.users.GetUserByID(c.Request.Context(), session.UserID)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusUnauthorized, "当前登录账号已不可用,请重新登录", err)
|
||||
return
|
||||
}
|
||||
if user.Status != repository.UserStatusActive {
|
||||
s.writeError(c, http.StatusForbidden, "当前账号已被禁用", nil)
|
||||
return
|
||||
}
|
||||
if _, err := auth.AuthenticateAccount(session.Username, request.CurrentPassword, user.Username, user.Email, user.PasswordHash, user.Role); err != nil {
|
||||
s.writeError(c, http.StatusUnauthorized, "当前密码错误", nil)
|
||||
return
|
||||
}
|
||||
if err := passwordpolicy.ValidateAccountPassword(request.NewPassword, user.Username); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "生成新密码摘要失败", err)
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
if err := s.users.UpdateUser(c.Request.Context(), user); err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "保存新密码失败", err)
|
||||
return
|
||||
}
|
||||
s.finishPasswordChange(c, session.UserID, "密码修改成功,请使用新密码重新登录")
|
||||
}
|
||||
|
||||
func (s *Server) finishPasswordChange(c *gin.Context, userID, message string) {
|
||||
s.invalidateUserSessions(userID)
|
||||
s.clearAuthCookie(c)
|
||||
s.recordAuthAudit(c, "修改密码", "success", "当前登录账号修改密码成功")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"authenticated": false,
|
||||
"requires_login": true,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
160
backend/internal/httpapi/password_api_test.go
Normal file
160
backend/internal/httpapi/password_api_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestChangePasswordUpdatesPrimarySuperAdminAndInvalidatesSession(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
cfg := config.Default(directory)
|
||||
cfg.SetupReady = true
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.Email = "admin@example.test"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
cfg.DNS.VerificationState = "verified"
|
||||
cfg.Web.Hostname = "scert.example.test"
|
||||
configPath := filepath.Join(directory, "config", "scert.json")
|
||||
if err := cfg.Save(configPath); err != nil {
|
||||
t.Fatalf("保存改密测试配置失败:%v", err)
|
||||
}
|
||||
t.Setenv("SCERT_WEB_ENABLED", "false")
|
||||
server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}})
|
||||
login := loginAuthTest(t, server, "admin", "AdminPass123", false)
|
||||
cookie := login.Result().Cookies()[0]
|
||||
|
||||
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"AdminPass123","new_password":"NewAdmin123","confirm_password":"NewAdmin123"}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("超级管理员改密失败:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
RequiresLogin bool `json:"requires_login"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("解析改密响应失败:%v", err)
|
||||
}
|
||||
if payload.Authenticated || !payload.RequiresLogin {
|
||||
t.Fatalf("改密响应未要求重新登录:%+v", payload)
|
||||
}
|
||||
|
||||
oldSession := authSessionRequest(server, cookie)
|
||||
if oldSession.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("改密后旧会话应失效,实际为 %d:%s", oldSession.Code, oldSession.Body.String())
|
||||
}
|
||||
oldLogin := performLoginRequest(t, server, "admin", "AdminPass123")
|
||||
if oldLogin.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("改密后旧密码不应登录成功,实际为 %d", oldLogin.Code)
|
||||
}
|
||||
newLogin := loginAuthTest(t, server, "admin", "NewAdmin123", false)
|
||||
if newLogin.Code != http.StatusOK {
|
||||
t.Fatalf("改密后新密码登录失败:%d %s", newLogin.Code, newLogin.Body.String())
|
||||
}
|
||||
|
||||
saved, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取改密后的配置失败:%v", err)
|
||||
}
|
||||
if saved.Admin.PasswordHash == cfg.Admin.PasswordHash || saved.Admin.PasswordHash == "" {
|
||||
t.Fatal("改密后的配置没有保存新的密码摘要")
|
||||
}
|
||||
if saved.Web.Enabled != cfg.Web.Enabled {
|
||||
t.Fatal("修改密码不应把环境变量覆盖值写回配置文件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordUpdatesDatabaseUser(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
cfg := config.Default(directory)
|
||||
cfg.SetupReady = true
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.Email = "admin@example.test"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
cfg.DNS.VerificationState = "verified"
|
||||
cfg.Web.Hostname = "scert.example.test"
|
||||
cfg.Database.DSN = filepath.Join(directory, "data", "change-password.db")
|
||||
db, err := database.Open(t.Context(), cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据库改密测试数据库失败:%v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||
t.Fatalf("迁移数据库改密测试数据库失败:%v", err)
|
||||
}
|
||||
users := repository.NewSQLUserRepository(db.DB, "sqlite")
|
||||
if err := users.CreateUser(t.Context(), repository.User{
|
||||
ID: "operator-1", Username: "operator", Email: "operator@example.test",
|
||||
PasswordHash: httpUserPasswordHash(t, "Operator123"), Role: repository.UserRoleAdmin, Status: repository.UserStatusActive,
|
||||
}); err != nil {
|
||||
t.Fatalf("创建数据库改密测试用户失败:%v", err)
|
||||
}
|
||||
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, Users: users})
|
||||
login := loginAuthTest(t, server, "operator", "Operator123", false)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("数据库用户登录失败:%d %s", login.Code, login.Body.String())
|
||||
}
|
||||
cookie := login.Result().Cookies()[0]
|
||||
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"Operator123","new_password":"NewOper123","confirm_password":"NewOper123"}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("数据库用户改密失败:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if oldSession := authSessionRequest(server, cookie); oldSession.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("数据库用户改密后旧会话应失效,实际为 %d", oldSession.Code)
|
||||
}
|
||||
if newLogin := loginAuthTest(t, server, "operator@example.test", "NewOper123", false); newLogin.Code != http.StatusOK {
|
||||
t.Fatalf("数据库用户改密后新密码登录失败:%d %s", newLogin.Code, newLogin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsInvalidInput(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
cfg := config.Default(directory)
|
||||
cfg.SetupReady = true
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.Email = "admin@example.test"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
cfg.DNS.VerificationState = "verified"
|
||||
cfg.Web.Hostname = "scert.example.test"
|
||||
cfgPath := filepath.Join(directory, "config", "scert.json")
|
||||
if err := cfg.Save(cfgPath); err != nil {
|
||||
t.Fatalf("保存非法改密测试配置失败:%v", err)
|
||||
}
|
||||
server := New(Dependencies{Config: cfg, ConfigPath: cfgPath, Repository: &stubDomainRepository{}})
|
||||
login := loginAuthTest(t, server, "admin", "AdminPass123", false)
|
||||
cookie := login.Result().Cookies()[0]
|
||||
response := performChangePasswordRequest(t, server, cookie, `{"current_password":"wrong-password","new_password":"short","confirm_password":"different"}`)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法改密请求应先返回参数错误,实际为 %d:%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "两次输入的新密码不一致") {
|
||||
t.Fatalf("非法改密响应未说明确认密码错误:%s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func performChangePasswordRequest(t *testing.T, server *Server, cookie *http.Cookie, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func performLoginRequest(t *testing.T, server *Server, identifier, password string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := `{"identifier":"` + identifier + `","password":"` + password + `","remember":false}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
@ -147,6 +147,7 @@ func (s *Server) registerRoutes() {
|
||||
s.router.GET("/api/v1/auth/session", s.authSession)
|
||||
s.router.POST("/api/v1/auth/login", s.authLogin)
|
||||
s.router.POST("/api/v1/auth/logout", s.authLogout)
|
||||
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
|
||||
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
|
||||
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
|
||||
userAPI := s.router.Group("/api/v1/users", s.requireSuperAdmin)
|
||||
|
||||
@ -3024,4 +3024,22 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
|
||||
### 76.2 验证状态
|
||||
|
||||
- `[✓]` `backend/service` 环境配置专项测试通过。
|
||||
- `[!]` 真实 Wails 桌面打包、Windows Service 和 Linux systemd 实机验收仍需在授权目标环境执行。
|
||||
- `[!]` 真实 Wails 桌面打包、Windows Service 和 Linux systemd 实机验收仍需在授权目标环境执行。
|
||||
|
||||
## 七十七、登录账号自助修改密码(2026-08-30)
|
||||
|
||||
### 77.1 本轮需求清单
|
||||
|
||||
- `[✓]` 增加已登录账号修改自身密码的 API,接口受登录状态和同源请求保护。
|
||||
- `[✓]` 修改密码前校验当前密码、两次新密码一致性和 8 到 12 个字符的密码策略。
|
||||
- `[✓]` 支持配置中的超级管理员密码持久化到配置文件,并同步数据库中的主超级管理员摘要。
|
||||
- `[✓]` 支持数据库用户修改自身密码,并在保存后使该用户的旧会话全部失效。
|
||||
- `[✓]` 修改成功后清除当前会话 Cookie,要求使用新密码重新登录,避免继续使用旧会话。
|
||||
- `[✓]` Web 管理页面增加“修改密码”弹窗入口,成功后自动返回登录页面。
|
||||
|
||||
### 77.2 接口与验证状态
|
||||
|
||||
- `[✓]` 新增 `POST /api/v1/auth/password`,请求字段为 `current_password`、`new_password` 和 `confirm_password`。
|
||||
- `[✓]` 后端专项测试覆盖超级管理员、数据库用户、旧会话失效、错误输入和新密码登录。
|
||||
- `[✓]` 后端 `go test ./...` 和网页端 `npm.cmd run build` 已通过。
|
||||
- `[!]` 真实部署环境的浏览器视觉复核、Wails 打包产物和远程 HTTPS 入口仍需目标环境验收。
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import ChangePassword from "../components/ChangePassword/ChangePassword.vue";
|
||||
import UserManagement from "../UserManagement/UserManagement.vue";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
|
||||
@ -115,6 +116,7 @@ const {
|
||||
<h1>证书管理总览</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<ChangePassword />
|
||||
<button class="secondary-button" type="button" @click="loadData">
|
||||
刷新状态
|
||||
</button>
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
.change-password-modal {
|
||||
width: min(100%, 560px);
|
||||
}
|
||||
|
||||
.change-password-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.change-password-form small {
|
||||
color: #8793a7;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.change-password-notice,
|
||||
.change-password-error {
|
||||
margin: 0;
|
||||
padding: 11px 13px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.change-password-notice {
|
||||
border: 1px solid #bfe7d1;
|
||||
color: #24734b;
|
||||
background: #effbf4;
|
||||
}
|
||||
|
||||
.change-password-error {
|
||||
border: 1px solid #f0c4c4;
|
||||
color: #9c3b3b;
|
||||
background: #fff2f2;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.change-password-modal {
|
||||
width: min(100%, calc(100vw - 28px));
|
||||
}
|
||||
}
|
||||
171
frontend/src/views/components/ChangePassword/ChangePassword.vue
Normal file
171
frontend/src/views/components/ChangePassword/ChangePassword.vue
Normal file
@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref } from "vue";
|
||||
|
||||
const visible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const form = ref({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
let redirectTimer = 0;
|
||||
|
||||
function open() {
|
||||
visible.value = true;
|
||||
success.value = false;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (submitting.value || success.value) {
|
||||
return;
|
||||
}
|
||||
visible.value = false;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
form.value = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
if (form.value.newPassword !== form.value.confirmPassword) {
|
||||
error.value = "两次输入的新密码不一致。";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/password", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
current_password: form.value.currentPassword,
|
||||
new_password: form.value.newPassword,
|
||||
confirm_password: form.value.confirmPassword,
|
||||
}),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || "修改密码失败,请稍后重试。");
|
||||
}
|
||||
success.value = true;
|
||||
notice.value = result.message || "密码修改成功,请重新登录。";
|
||||
redirectTimer = window.setTimeout(() => {
|
||||
window.location.href = "/login";
|
||||
}, 1600);
|
||||
} catch (submitError) {
|
||||
error.value = submitError.message || "修改密码失败,请稍后重试。";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (redirectTimer) {
|
||||
window.clearTimeout(redirectTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="secondary-button" type="button" @click="open">
|
||||
修改密码
|
||||
</button>
|
||||
|
||||
<div v-if="visible" class="modal-backdrop" @click.self="close">
|
||||
<section
|
||||
class="modal-card change-password-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="change-password-title"
|
||||
>
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
<p class="eyebrow">账户安全</p>
|
||||
<h2 id="change-password-title">修改登录密码</h2>
|
||||
</div>
|
||||
<button
|
||||
class="close-button"
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
:disabled="submitting || success"
|
||||
@click="close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="change-password-form" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span>当前密码</span>
|
||||
<input
|
||||
v-model="form.currentPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入当前密码"
|
||||
:disabled="submitting || success"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新密码 <small>8 到 12 个字符,需同时包含字母和数字</small></span>
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
placeholder="请输入新密码"
|
||||
:disabled="submitting || success"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>确认新密码</span>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
placeholder="请再次输入新密码"
|
||||
:disabled="submitting || success"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p v-if="notice" class="change-password-notice">{{ notice }}</p>
|
||||
<p v-if="error" class="change-password-error">{{ error }}</p>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="submitting || success"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button class="primary-button" type="submit" :disabled="submitting || success">
|
||||
{{ submitting ? "正在保存" : success ? "已完成" : "保存新密码" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="./ChangePassword.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user