群主转让加一步密码二次确认,跟账号注销/改密码等其它高风险操作保持一致(第6章复测发现)

serveIMGroupOwnerTransfer 核实过:只认 {target_user_id, version},没有任何
action_token/二次验证机制——不是文档滞后,是这个操作本身就没有服务端强制的二次
确认。但转让群主同样不可逆、后果同样重大("确定后你将立即失去群主身份"),本项目
对同一类场景已有明确先例:personal-settings-view.jsx 的 DeleteAccountConfirm 就是
纯前端加的密码输入门槛,不指望服务端校验它,只是让"手滑点确定"变成需要多打一遍
密码才会发生的事。

OwnerSelect 原来点目标人选后只有一层"确定/取消"的确认表,点确定直接调用真实转让
接口。现在拆成两步:第一步维持原有确认文案不变,第二步新增密码输入,留空点确定
会被拦下(不发请求),填了任意内容才真正调用 transferGroupOwner。

真实验证(真实群+真实候选人):空密码点确定确认没有发出请求;填入密码后
transferGroupOwner 真实调用成功,正确跳转回群组管理页。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 17:04:26 +08:00
co-authored by Claude Opus 5
parent 542aca3dc2
commit 7728afe79f
4 changed files with 73 additions and 8 deletions
+11
View File
@@ -9513,6 +9513,17 @@ div.toast.show {
line-height: 1.6;
}
.group-password-input {
width: 100%;
height: 2.75rem;
margin-top: 0.6rem;
padding: 0 0.9rem;
border: 1px solid #dde1e6;
border-radius: 0.35rem;
font-size: 1rem;
text-align: left;
}
.people-confirm-sheet button,
.group-source-sheet button,
.group-confirm-sheet button {
+20 -3
View File
@@ -831,6 +831,13 @@ function OwnerSelect({topic, onToast}) {
const [members, setMembers] = React.useState(null);
const [query, setQuery] = React.useState('');
const [target, setTarget] = React.useState(null);
// 后端 serveIMGroupOwnerTransfer 只认 {target_user_id, version},没有任何二次验证/action_token
// 机制(跟青少年模式关闭那种真的会被服务端拒绝的场景不一样)。群主转让同样不可逆、后果同样
// 重大,照抄本项目已有的处理这类"后端没有二次验证但操作本身很危险"场景的方式——见
// personal-settings-view.jsx 的 DeleteAccountConfirm:加一步前端自己的密码输入门槛,不追加到
// 请求体、不指望服务端校验它,只是让"手滑点确定"变成需要多一步真实操作才会发生的事。
const [passwordStep, setPasswordStep] = React.useState(false);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
@@ -841,7 +848,9 @@ function OwnerSelect({topic, onToast}) {
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
const confirm = () => {
if (!password) { onToast('请输入密码以确认'); return; }
if (busy || !target) return;
setBusy(true);
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
@@ -859,11 +868,19 @@ function OwnerSelect({topic, onToast}) {
{others.length ? <><div className="group-section-title">群成员</div>{others.map(row)}</> : null}
</>}
</main>
{target ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
{target && !passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>选择新群主</h2>
<p>{target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>确定</button>
<button type="button" onClick={() => setTarget(null)}>取消</button>
<button type="button" className="danger" onClick={() => setPasswordStep(true)}>确定</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
{target && passwordStep ? <div className="group-sheet-backdrop"><section className="group-confirm-sheet">
<h2>输入密码确认转让</h2>
<p>请输入登录密码以确认将群主转让给{target.group_nickname || target.display_name}(该操作立即生效,不可撤销)。</p>
<input type="password" autoFocus className="group-password-input" value={password}
onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
<button type="button" className="danger" disabled={busy} onClick={confirm}>{busy ? '处理中…' : '确定'}</button>
<button type="button" onClick={reset}>取消</button>
</section></div> : null}
</div>;
}
+41 -4
View File
@@ -12097,6 +12097,8 @@ function OwnerSelect({
const [members, setMembers] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
const [query, setQuery] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [target, setTarget] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(null);
const [passwordStep, setPasswordStep] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [password, setPassword] = react__WEBPACK_IMPORTED_MODULE_0___default().useState('');
const [busy, setBusy] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const [loadError, setLoadError] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
react__WEBPACK_IMPORTED_MODULE_0___default().useEffect(() => {
@@ -12111,7 +12113,16 @@ function OwnerSelect({
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => {
setTarget(null);
setPasswordStep(false);
setPassword('');
};
const confirm = () => {
if (!password) {
onToast('请输入密码以确认');
return;
}
if (busy || !target) return;
setBusy(true);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.transferGroupOwner)(topic, target.user_id).then(() => {
@@ -12159,7 +12170,7 @@ function OwnerSelect({
}, void 0, false), others.map(row)]
}, void 0, true) : null]
}, void 0, true)
}, void 0, false), target ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
}, void 0, false), target && !passwordStep ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-sheet-backdrop",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("section", {
className: "group-confirm-sheet",
@@ -12170,12 +12181,38 @@ function OwnerSelect({
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
className: "danger",
disabled: busy,
onClick: confirm,
onClick: () => setPasswordStep(true),
children: "确定"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => setTarget(null),
onClick: reset,
children: "取消"
}, void 0, false)]
}, void 0, true)
}, void 0, false) : null, target && passwordStep ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("div", {
className: "group-sheet-backdrop",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("section", {
className: "group-confirm-sheet",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("h2", {
children: "输入密码确认转让"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("p", {
children: ["请输入登录密码以确认将群主转让给", target.group_nickname || target.display_name, "(该操作立即生效,不可撤销)。"]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("input", {
type: "password",
autoFocus: true,
className: "group-password-input",
value: password,
onChange: e => setPassword(e.target.value),
placeholder: "登录密码"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
className: "danger",
disabled: busy,
onClick: confirm,
children: busy ? '处理中…' : '确定'
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: reset,
children: "取消"
}, void 0, false)]
}, void 0, true)
+1 -1
View File
File diff suppressed because one or more lines are too long