群主转让加一步密码二次确认,跟账号注销/改密码等其它高风险操作保持一致(第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
+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>;
}