411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import styles from './Join.module.css';
|
||
|
||
interface Share {
|
||
id: string;
|
||
walletName: string;
|
||
publicKey: string;
|
||
sessionId: string;
|
||
threshold: { t: number; n: number };
|
||
}
|
||
|
||
interface SignPartyInfo {
|
||
party_id: string;
|
||
party_index: number;
|
||
}
|
||
|
||
interface SessionInfo {
|
||
sessionId: string;
|
||
keygenSessionId: string;
|
||
walletName: string;
|
||
messageHash: string;
|
||
threshold: { t: number; n: number };
|
||
status: string;
|
||
currentParticipants: number;
|
||
parties?: SignPartyInfo[];
|
||
}
|
||
|
||
interface ValidateResult {
|
||
success: boolean;
|
||
error?: string;
|
||
sessionInfo?: SessionInfo;
|
||
joinToken?: string;
|
||
}
|
||
|
||
export default function CoSignJoin() {
|
||
const { inviteCode } = useParams<{ inviteCode?: string }>();
|
||
const navigate = useNavigate();
|
||
|
||
const [code, setCode] = useState(inviteCode || '');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||
const [joinToken, setJoinToken] = useState<string | null>(null);
|
||
const [step, setStep] = useState<'input' | 'select_share' | 'joining'>('input');
|
||
|
||
// Share 选择相关
|
||
const [shares, setShares] = useState<Share[]>([]);
|
||
const [selectedShareId, setSelectedShareId] = useState('');
|
||
const [sharePassword, setSharePassword] = useState('');
|
||
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
|
||
|
||
// 加载本地 shares
|
||
useEffect(() => {
|
||
const loadShares = async () => {
|
||
try {
|
||
const result = await window.electronAPI.storage.listShares();
|
||
// 兼容不同返回格式
|
||
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
|
||
console.log('[CoSignJoin] Loaded shares:', shareList.map((s: Share) => ({
|
||
id: s.id,
|
||
sessionId: s.sessionId,
|
||
walletName: s.walletName,
|
||
})));
|
||
setShares(shareList);
|
||
} catch (err) {
|
||
console.error('Failed to load shares:', err);
|
||
}
|
||
};
|
||
loadShares();
|
||
}, []);
|
||
|
||
// 如果 URL 中有邀请码,自动验证
|
||
useEffect(() => {
|
||
if (inviteCode) {
|
||
handleValidateCode(inviteCode);
|
||
}
|
||
}, [inviteCode]);
|
||
|
||
// 自动选择匹配的 share
|
||
useEffect(() => {
|
||
if (sessionInfo && shares.length > 0 && !selectedShareId) {
|
||
// 尝试找到匹配的 share(基于 keygen session ID)
|
||
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||
console.log('[CoSignJoin] Auto-select share check:', {
|
||
keygenSessionId: sessionInfo.keygenSessionId,
|
||
sharesSessionIds: shares.map(s => s.sessionId),
|
||
matchingShare: matchingShare ? { id: matchingShare.id, sessionId: matchingShare.sessionId } : null,
|
||
});
|
||
if (matchingShare) {
|
||
console.log('[CoSignJoin] Auto-selecting matching share:', matchingShare.id);
|
||
setSelectedShareId(matchingShare.id);
|
||
} else if (shares.length === 1) {
|
||
// 如果只有一个 share,自动选择
|
||
console.log('[CoSignJoin] Auto-selecting only share:', shares[0].id);
|
||
setSelectedShareId(shares[0].id);
|
||
} else {
|
||
console.log('[CoSignJoin] No matching share found, user must select manually');
|
||
}
|
||
}
|
||
}, [sessionInfo, shares, selectedShareId]);
|
||
|
||
// 自动加入
|
||
useEffect(() => {
|
||
console.log('[CoSignJoin] Auto-join check:', {
|
||
step,
|
||
hasSessionInfo: !!sessionInfo,
|
||
hasJoinToken: !!joinToken,
|
||
selectedShareId,
|
||
autoJoinAttempted,
|
||
isLoading,
|
||
sharesCount: shares.length,
|
||
});
|
||
|
||
if (
|
||
step === 'select_share' &&
|
||
sessionInfo &&
|
||
joinToken &&
|
||
selectedShareId &&
|
||
!autoJoinAttempted &&
|
||
!isLoading
|
||
) {
|
||
// 找到匹配的 share 且未尝试过自动加入,则自动加入
|
||
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||
console.log('[CoSignJoin] Auto-join conditions met, checking share match:', {
|
||
keygenSessionId: sessionInfo.keygenSessionId,
|
||
matchingShareId: matchingShare?.id,
|
||
selectedShareId,
|
||
isMatch: matchingShare && matchingShare.id === selectedShareId,
|
||
});
|
||
if (matchingShare && matchingShare.id === selectedShareId) {
|
||
console.log('[CoSignJoin] Auto-joining session...');
|
||
setAutoJoinAttempted(true);
|
||
handleJoinSession();
|
||
} else {
|
||
console.log('[CoSignJoin] Share mismatch, not auto-joining');
|
||
}
|
||
}
|
||
}, [step, sessionInfo, joinToken, selectedShareId, autoJoinAttempted, isLoading, shares]);
|
||
|
||
const handleValidateCode = async (codeToValidate: string) => {
|
||
console.log('[CoSignJoin] handleValidateCode called:', codeToValidate);
|
||
if (!codeToValidate.trim()) {
|
||
setError('请输入邀请码');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result: ValidateResult = await window.electronAPI.cosign.validateInviteCode(codeToValidate);
|
||
console.log('[CoSignJoin] validateInviteCode result:', {
|
||
success: result.success,
|
||
sessionInfo: result.sessionInfo,
|
||
hasJoinToken: !!result.joinToken,
|
||
joinTokenPreview: result.joinToken?.substring(0, 20) + '...',
|
||
error: result.error,
|
||
});
|
||
if (result.success && result.sessionInfo) {
|
||
setSessionInfo(result.sessionInfo);
|
||
if (result.joinToken) {
|
||
setJoinToken(result.joinToken);
|
||
} else {
|
||
console.warn('[CoSignJoin] WARNING: No joinToken in response!');
|
||
}
|
||
setStep('select_share');
|
||
} else {
|
||
setError(result.error || '无效的邀请码');
|
||
}
|
||
} catch (err) {
|
||
console.error('[CoSignJoin] validateInviteCode error:', err);
|
||
setError('验证邀请码失败,请检查网络连接');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleJoinSession = async () => {
|
||
console.log('[CoSignJoin] handleJoinSession called:', {
|
||
hasSessionInfo: !!sessionInfo,
|
||
hasJoinToken: !!joinToken,
|
||
selectedShareId,
|
||
});
|
||
|
||
if (!sessionInfo) {
|
||
setError('会话信息不完整');
|
||
return;
|
||
}
|
||
|
||
if (!joinToken) {
|
||
setError('未获取到加入令牌,请重新验证邀请码');
|
||
return;
|
||
}
|
||
|
||
if (!selectedShareId) {
|
||
setError('请选择一个钱包');
|
||
return;
|
||
}
|
||
|
||
setStep('joining');
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
console.log('[CoSignJoin] Calling cosign.joinSession with:', {
|
||
sessionId: sessionInfo.sessionId,
|
||
shareId: selectedShareId,
|
||
walletName: sessionInfo.walletName,
|
||
messageHash: sessionInfo.messageHash,
|
||
threshold: sessionInfo.threshold,
|
||
parties: sessionInfo.parties,
|
||
});
|
||
|
||
try {
|
||
const result = await window.electronAPI.cosign.joinSession({
|
||
sessionId: sessionInfo.sessionId,
|
||
shareId: selectedShareId,
|
||
sharePassword: sharePassword,
|
||
joinToken: joinToken,
|
||
walletName: sessionInfo.walletName,
|
||
messageHash: sessionInfo.messageHash,
|
||
threshold: sessionInfo.threshold,
|
||
parties: sessionInfo.parties,
|
||
});
|
||
console.log('[CoSignJoin] joinSession result:', result);
|
||
|
||
if (result.success) {
|
||
navigate(`/cosign/session/${sessionInfo.sessionId}`);
|
||
} else {
|
||
setError(result.error || '加入会话失败');
|
||
setStep('select_share');
|
||
}
|
||
} catch (err) {
|
||
setError('加入会话失败,请重试');
|
||
setStep('select_share');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handlePaste = async () => {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
setCode(text.trim());
|
||
} catch (err) {
|
||
console.error('Failed to read clipboard:', err);
|
||
}
|
||
};
|
||
|
||
const selectedShare = shares.find(s => s.id === selectedShareId);
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<div className={styles.card}>
|
||
<h1 className={styles.title}>加入多方签名</h1>
|
||
<p className={styles.subtitle}>输入邀请码加入签名会话</p>
|
||
|
||
{step === 'input' && (
|
||
<div className={styles.form}>
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>邀请码</label>
|
||
<div className={styles.inputWrapper}>
|
||
<input
|
||
type="text"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value)}
|
||
placeholder="粘贴签名邀请码"
|
||
className={styles.input}
|
||
disabled={isLoading}
|
||
/>
|
||
<button
|
||
className={styles.pasteButton}
|
||
onClick={handlePaste}
|
||
disabled={isLoading}
|
||
>
|
||
粘贴
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => navigate('/')}
|
||
disabled={isLoading}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => handleValidateCode(code)}
|
||
disabled={isLoading || !code.trim()}
|
||
>
|
||
{isLoading ? '验证中...' : '下一步'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'select_share' && sessionInfo && (
|
||
<div className={styles.form}>
|
||
<div className={styles.sessionInfo}>
|
||
<h3 className={styles.sessionTitle}>签名会话信息</h3>
|
||
<div className={styles.infoGrid}>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>钱包名称</span>
|
||
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>签名阈值</span>
|
||
<span className={styles.infoValue}>
|
||
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
|
||
</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>消息哈希</span>
|
||
<span className={styles.infoValue} style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||
{sessionInfo.messageHash.substring(0, 16)}...
|
||
</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>当前参与者</span>
|
||
<span className={styles.infoValue}>
|
||
{sessionInfo.currentParticipants} / {sessionInfo.threshold.t}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 选择本地 share */}
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>选择本地钱包</label>
|
||
{shares.length === 0 ? (
|
||
<p className={styles.hint}>暂无可用钱包,请先创建或加入共管钱包</p>
|
||
) : (
|
||
<select
|
||
value={selectedShareId}
|
||
onChange={(e) => setSelectedShareId(e.target.value)}
|
||
className={styles.input}
|
||
disabled={isLoading}
|
||
>
|
||
<option value="">请选择...</option>
|
||
{shares.map(share => (
|
||
<option key={share.id} value={share.id}>
|
||
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
|
||
{share.sessionId === sessionInfo.keygenSessionId ? ' [匹配]' : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{selectedShare && (
|
||
<p className={styles.hint}>
|
||
公钥: {selectedShare.publicKey.substring(0, 16)}...
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 钱包密码 */}
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>钱包密码 (可选)</label>
|
||
<input
|
||
type="password"
|
||
value={sharePassword}
|
||
onChange={(e) => setSharePassword(e.target.value)}
|
||
placeholder="如果设置了密码,请输入"
|
||
className={styles.input}
|
||
disabled={isLoading}
|
||
/>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => {
|
||
setStep('input');
|
||
setSessionInfo(null);
|
||
setJoinToken(null);
|
||
setSelectedShareId('');
|
||
setError(null);
|
||
setAutoJoinAttempted(false);
|
||
}}
|
||
disabled={isLoading}
|
||
>
|
||
返回
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={handleJoinSession}
|
||
disabled={isLoading || !selectedShareId}
|
||
>
|
||
{isLoading ? '加入中...' : '加入签名'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'joining' && (
|
||
<div className={styles.joining}>
|
||
<div className={styles.spinner}></div>
|
||
<p>正在加入签名会话...</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|