将密码找回指引切换为本机管理命令

This commit is contained in:
xuans 2026-09-21 01:28:11 +08:00
parent a0ed64d0b0
commit 6af7c5ab3b
7 changed files with 180 additions and 6 deletions

View File

@ -5,7 +5,6 @@ import (
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/admin"
"git.pcux.cn/xuans/Scert/backend/internal/auth"
)
@ -21,7 +20,6 @@ type passwordResetResolution struct {
Title string `json:"title"`
Description string `json:"description"`
Action string `json:"action"`
ResetPath string `json:"reset_path,omitempty"`
}
// resolvePasswordResetAccount 根据账户标识返回对应的密码处理内容。
@ -52,8 +50,7 @@ func (s *Server) resolvePasswordResetAccount(c *gin.Context) {
AccountLabel: "超级管理员",
Title: "超级管理员密码重置",
Description: "已识别为超级管理员账户。超级管理员不能通过普通重置页面修改密码,请使用 Scert-admin 开启的一次性重置入口。",
Action: "use_super_admin_entry",
ResetPath: admin.ResetPath,
Action: "local_admin_command",
})
return
}

View File

@ -0,0 +1,46 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPasswordResetResolutionOnlyOffersLocalAdminCommand(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "owner"
cfg.Admin.Email = "owner@example.test"
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
for _, identifier := range []string{"owner", "owner@example.test"} {
t.Run(identifier, func(t *testing.T) {
body, err := json.Marshal(map[string]string{"identifier": identifier})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("查询失败:%d %s", response.Code, response.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["account_type"] != "super_admin" || payload["action"] != "local_admin_command" {
t.Fatalf("超级管理员没有返回本机命令指引:%v", payload)
}
for _, field := range []string{"reset_path", "reset_url", "token", "nonce"} {
if _, exists := payload[field]; exists {
t.Fatalf("公开查询泄露了重置入口或会话字段:%s", field)
}
}
if len(response.Result().Cookies()) != 0 {
t.Fatal("公开查询不应创建重置会话")
}
})
}
}

View File

@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
"test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"

View File

@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { baseParse } from "@vue/compiler-dom";
import { parse } from "@vue/compiler-sfc";
import { usePasswordReset } from "./PasswordReset.js";
import { mountComposable } from "../../test-support/mount.js";
function template(path) {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
return baseParse(parse(source).descriptor.template.content);
}
function elements(node, predicate, matches = []) {
if (predicate(node)) matches.push(node);
for (const child of node.children || []) elements(child, predicate, matches);
return matches;
}
test("超级管理员查询保留本机处理标识,不生成网页重置链接", async (t) => {
const payload = {
account_type: "super_admin",
action: "local_admin_command",
title: "超级管理员密码重置",
description: "请在部署主机操作。",
};
const { state } = await mountComposable(t, usePasswordReset, async (url, options) => {
assert.equal(url, "/api/v1/password-reset/resolve");
assert.equal(options.credentials, "same-origin");
assert.deepEqual(JSON.parse(options.body), { identifier: "owner" });
return Response.json(payload);
});
state.identifier.value = " owner ";
await state.submit();
assert.equal(state.resolution.value.action, "local_admin_command");
assert.equal("reset_path" in state.resolution.value, false);
assert.equal(state.error.value, "");
assert.equal(state.submitting.value, false);
state.reset();
assert.equal(state.resolution.value, null);
});
test("本机命令指引仅对接口明确标记的处理方式显示", () => {
const root = template("./PasswordReset.vue");
const guides = elements(root, (node) => node.tag === "LocalResetGuide");
assert.equal(guides.length, 1);
assert.equal(guides[0].props.find((prop) => prop.name === "if").exp.content,
"resolution.action === 'local_admin_command'");
const message = elements(root, (node) => node.tag === "p"
&& node.props.some((prop) => prop.name === "if"
&& prop.exp?.content === "message && !resolution"));
assert.equal(message.length, 1);
});
test("本机指引只显示固定命令,解释放入悬浮提示", () => {
const root = template("../components/LocalResetGuide/LocalResetGuide.vue");
assert.equal(elements(root, (node) => ["a", "input", "form"].includes(node.tag)).length, 0);
assert.equal(elements(root, (node) => node.tag === "HelpTip").length, 1);
const commands = elements(root, (node) => node.tag === "code")
.map((node) => node.children.filter((child) => child.type === 2)
.map((child) => child.content).join(""));
assert.deepEqual(commands, ["systemctl start Scert-admin", ".\\scert-admin.exe"]);
});

View File

@ -1,5 +1,6 @@
<script setup>
import AccessShell from "../components/AccessShell/AccessShell.vue";
import LocalResetGuide from "../components/LocalResetGuide/LocalResetGuide.vue";
import { usePasswordReset } from "./PasswordReset.js";
const {
@ -47,13 +48,14 @@ const {
/>
</label>
<p v-if="message" class="access-message">{{ message }}</p>
<p v-if="message && !resolution" class="access-message">{{ message }}</p>
<p v-if="error" class="access-error">{{ error }}</p>
<div v-if="resolution" class="reset-resolution" aria-live="polite">
<span class="reset-resolution-label">处理方式</span>
<strong>{{ resolution.title }}</strong>
<p>{{ resolution.description }}</p>
<LocalResetGuide v-if="resolution.action === 'local_admin_command'" />
<a
v-if="resolution.reset_path"
class="access-submit reset-entry-link"

View File

@ -0,0 +1,38 @@
.local-reset-guide {
display: grid;
gap: 8px;
min-width: 0;
}
.local-reset-guide__heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
}
.local-reset-guide__commands {
display: grid;
gap: 6px;
margin: 0;
font-size: 12px;
}
.local-reset-guide__commands > div {
display: grid;
grid-template-columns: 58px minmax(0, 1fr);
align-items: baseline;
gap: 8px;
}
.local-reset-guide__commands dt,
.local-reset-guide__commands dd {
min-width: 0;
margin: 0;
}
.local-reset-guide__commands code {
overflow-wrap: anywhere;
font-family: Consolas, monospace;
}

View File

@ -0,0 +1,28 @@
<script setup>
import HelpTip from "../HelpTip/HelpTip.vue";
</script>
<template>
<section class="local-reset-guide">
<div class="local-reset-guide__heading">
<span>本机管理命令</span>
<HelpTip>
请由部署管理员在 Scert 所在主机执行命令。Linux 使用 root 终端;
Windows 使用提升权限的控制台,在安装目录运行。
验证码和新密码仅在本机终端输入,本页面不会开启重置会话。
</HelpTip>
</div>
<dl class="local-reset-guide__commands">
<div>
<dt>Linux</dt>
<dd><code>systemctl start Scert-admin</code></dd>
</div>
<div>
<dt>Windows</dt>
<dd><code>.\scert-admin.exe</code></dd>
</div>
</dl>
</section>
</template>
<style scoped src="./LocalResetGuide.css"></style>