update project
This commit is contained in:
34
apps/web/Dockerfile
Normal file
34
apps/web/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything (filtered by .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Install deps with hoisting
|
||||
RUN echo "node-linker=hoisted" > .npmrc
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build web
|
||||
ENV NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
RUN cd apps/web && ../../node_modules/.bin/next build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -42,6 +42,9 @@ export default function BridgePage() {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.push('/login');
|
||||
}, [router, user]);
|
||||
|
||||
const destChainOptions = useMemo(() => getDestinationChainOptions(sourceChain), [sourceChain]);
|
||||
const sourceTokenOptions = useMemo(() => getTokenOptions(sourceChain), [sourceChain]);
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useBalances } from '@/hooks/useBalances';
|
||||
import type { ChainBalance } from '@/lib/balances/types';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, wallets } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { user, wallets, logout } = useAuthStore();
|
||||
const { portfolio, loading, refreshing, error, refresh } = useBalances();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 820, margin: '50px auto', padding: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<h1>Dashboard</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<span>{user?.email || 'Not authenticated'}</span>
|
||||
<span>{user.email}</span>
|
||||
<Link href="/send" style={navButtonStyle}>
|
||||
Send
|
||||
</Link>
|
||||
@@ -33,6 +44,9 @@ export default function DashboardPage() {
|
||||
<button onClick={() => void refresh()} style={navButtonStyle} disabled={loading || refreshing}>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button onClick={logout} style={{ padding: '6px 12px' }}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
125
apps/web/src/app/login/page.tsx
Normal file
125
apps/web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
type Step = 'email' | 'code';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { loginStart, loginComplete, loading, error, clearError } = useAuthStore();
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
await loginStart(email);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
if (!state.error) {
|
||||
setStep('code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
await loginComplete(email, password, code);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
if (state.user) {
|
||||
if (!state.mnemonicShown) {
|
||||
router.push('/mnemonic');
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
|
||||
<h1>Login</h1>
|
||||
|
||||
{step === 'email' && (
|
||||
<form onSubmit={handleEmailSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Email</label><br />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
{loading ? 'Sending code...' : 'Send verification code'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'code' && (
|
||||
<form onSubmit={handleCodeSubmit}>
|
||||
<p style={{ color: '#666', fontSize: 13, marginBottom: 16 }}>
|
||||
A verification code was sent to <strong>{email}</strong>.
|
||||
</p>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Verification code</label><br />
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
style={{ width: '100%', padding: 8, letterSpacing: 4, fontSize: 18, textAlign: 'center' }}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Password</label><br />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setCode(''); setPassword(''); clearError(); }}
|
||||
style={{ marginLeft: 8, padding: '8px 16px', background: '#fff', border: '1px solid #ccc', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
No account? <a href="/register">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
apps/web/src/app/mnemonic/page.tsx
Normal file
148
apps/web/src/app/mnemonic/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
export default function MnemonicPage() {
|
||||
const router = useRouter();
|
||||
const { mnemonic, wallets, confirmMnemonic } = useAuthStore();
|
||||
const [step, setStep] = useState<'show' | 'verify' | 'keys'>('show');
|
||||
const [answers, setAnswers] = useState<Record<number, string>>({});
|
||||
const [verifyError, setVerifyError] = useState('');
|
||||
|
||||
const words = useMemo(() => mnemonic?.split(' ') || [], [mnemonic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mnemonic) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [mnemonic, router]);
|
||||
|
||||
const quizIndices = useMemo(() => {
|
||||
if (words.length < 3) return [];
|
||||
const indices: number[] = [];
|
||||
while (indices.length < 3) {
|
||||
const idx = Math.floor(Math.random() * words.length);
|
||||
if (!indices.includes(idx)) indices.push(idx);
|
||||
}
|
||||
return indices.sort((a, b) => a - b);
|
||||
}, [words.length]);
|
||||
|
||||
if (!mnemonic) return null;
|
||||
|
||||
const handleVerify = () => {
|
||||
setVerifyError('');
|
||||
for (const idx of quizIndices) {
|
||||
if (answers[idx]?.trim().toLowerCase() !== words[idx]) {
|
||||
setVerifyError(`Wrong word for position #${idx + 1}. Try again.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setStep('keys');
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await confirmMnemonic();
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
void navigator.clipboard.writeText('');
|
||||
} catch {
|
||||
// Document may have lost focus; ignore
|
||||
}
|
||||
}, 60000);
|
||||
} catch {
|
||||
// Fallback for older browsers or denied permission
|
||||
}
|
||||
};
|
||||
|
||||
if (step === 'show') {
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '50px auto', padding: 20 }}>
|
||||
<h1>Save Your Mnemonic Phrase</h1>
|
||||
<p style={{ color: 'red', fontWeight: 'bold' }}>
|
||||
Write these words down and store them safely. You will NOT be able to see them again!
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, margin: '20px 0' }}>
|
||||
{words.map((word, i) => (
|
||||
<div key={i} style={{ padding: 8, border: '1px solid #ccc' }}>
|
||||
<strong>{i + 1}.</strong> {word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => copyToClipboard(mnemonic)} style={{ padding: '8px 16px', marginRight: 8 }}>
|
||||
Copy Mnemonic
|
||||
</button>
|
||||
<button onClick={() => setStep('verify')} style={{ padding: '8px 16px' }}>
|
||||
Next: Verify
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'verify') {
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '50px auto', padding: 20 }}>
|
||||
<h1>Verify Mnemonic</h1>
|
||||
<p>Enter the following words from your mnemonic to confirm you saved it.</p>
|
||||
{quizIndices.map((idx) => (
|
||||
<div key={idx} style={{ marginBottom: 12 }}>
|
||||
<label>Word #{idx + 1}</label><br />
|
||||
<input
|
||||
type="text"
|
||||
value={answers[idx] || ''}
|
||||
onChange={(e) => setAnswers({ ...answers, [idx]: e.target.value })}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{verifyError && <p style={{ color: 'red' }}>{verifyError}</p>}
|
||||
<button onClick={() => setStep('show')} style={{ padding: '8px 16px', marginRight: 8 }}>
|
||||
Back
|
||||
</button>
|
||||
<button onClick={handleVerify} style={{ padding: '8px 16px' }}>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '50px auto', padding: 20 }}>
|
||||
<h1>Your Private Keys</h1>
|
||||
<p style={{ color: 'red', fontWeight: 'bold' }}>
|
||||
Save these private keys. They will NOT be shown again!
|
||||
</p>
|
||||
{wallets.map((w) => (
|
||||
<div key={w.chain} style={{ border: '1px solid #ccc', padding: 12, marginBottom: 12 }}>
|
||||
<h3>{w.chain}</h3>
|
||||
<p><strong>Address:</strong> {w.address}</p>
|
||||
<p style={{ wordBreak: 'break-all' }}>
|
||||
<strong>Private Key:</strong> {w.privateKey}
|
||||
</p>
|
||||
<button onClick={() => copyToClipboard(w.privateKey)} style={{ padding: '4px 12px' }}>
|
||||
Copy Key
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<label>
|
||||
<input type="checkbox" id="confirm-checkbox" />
|
||||
{' '}I have saved all my private keys
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
style={{ padding: '8px 24px', marginTop: 12 }}
|
||||
>
|
||||
Continue to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/dashboard');
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ export default function ReceivePage() {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.push('/login');
|
||||
}, [user, router]);
|
||||
|
||||
// Reset token when chain changes
|
||||
useEffect(() => {
|
||||
|
||||
144
apps/web/src/app/register/page.tsx
Normal file
144
apps/web/src/app/register/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
type Step = 'email' | 'code';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { registerStart, registerComplete, loading, error, clearError } = useAuthStore();
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setLocalError(null);
|
||||
await registerStart(email);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
if (!state.error) {
|
||||
setStep('code');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setLocalError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
await registerComplete(email, password, code);
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
if (state.user) {
|
||||
router.push('/mnemonic');
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = localError || error;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
|
||||
<h1>Register</h1>
|
||||
|
||||
{step === 'email' && (
|
||||
<form onSubmit={handleEmailSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Email</label><br />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
{displayError && <p style={{ color: 'red' }}>{displayError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
{loading ? 'Sending code...' : 'Send verification code'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'code' && (
|
||||
<form onSubmit={handleCodeSubmit}>
|
||||
<p style={{ color: '#666', fontSize: 13, marginBottom: 16 }}>
|
||||
A verification code was sent to <strong>{email}</strong>.
|
||||
</p>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Verification code</label><br />
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
style={{ width: '100%', padding: 8, letterSpacing: 4, fontSize: 18, textAlign: 'center' }}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Password (min 8 characters)</label><br />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>Confirm password</label><br />
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ width: '100%', padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
{displayError && <p style={{ color: 'red' }}>{displayError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ padding: '8px 24px', background: '#007bff', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setCode(''); setPassword(''); setConfirmPassword(''); clearError(); setLocalError(null); }}
|
||||
style={{ marginLeft: 8, padding: '8px 16px', background: '#fff', border: '1px solid #ccc', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 16 }}>
|
||||
Already have an account? <a href="/login">Login</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,9 @@ export default function SendPage() {
|
||||
const [result, setResult] = useState<SendResult | null>(null);
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.push('/login');
|
||||
}, [user, router]);
|
||||
|
||||
// Reset token on chain change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { SeedPhraseModal } from '@/components/SeedPhraseModal';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuthStore();
|
||||
const [showSeedModal, setShowSeedModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: '50px auto', padding: 20 }}>
|
||||
@@ -16,15 +28,40 @@ export default function SettingsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Security</h3>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16 }}>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>Seed Phrase</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>
|
||||
View your 12-word recovery phrase. Requires password verification.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSeedModal(true)}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<div style={{ border: '1px solid #ccc', borderRadius: 4, padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Account</h3>
|
||||
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>Email</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user?.email || 'Not authenticated'}</p>
|
||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: 13 }}>{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SeedPhraseModal
|
||||
isOpen={showSeedModal}
|
||||
onClose={() => setShowSeedModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,3 +80,15 @@ const navButtonStyle: React.CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
};
|
||||
|
||||
const primaryButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
@@ -66,6 +66,11 @@ export default function SwapPage() {
|
||||
[chain, amount, fromSymbol, slippageBps, toSymbol]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [router, user]);
|
||||
|
||||
useEffect(() => {
|
||||
estimateOutput(request);
|
||||
|
||||
283
apps/web/src/components/SeedPhraseModal.tsx
Normal file
283
apps/web/src/components/SeedPhraseModal.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { walletApi } from '@/lib/api';
|
||||
import { decryptVault } from '@/lib/crypto/vault';
|
||||
|
||||
interface SeedPhraseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AUTO_HIDE_SECONDS = 60;
|
||||
const CLIPBOARD_CLEAR_SECONDS = 30;
|
||||
|
||||
export function SeedPhraseModal({ isOpen, onClose }: SeedPhraseModalProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [mnemonic, setMnemonic] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(AUTO_HIDE_SECONDS);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clipboardTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const clearSensitiveData = useCallback(() => {
|
||||
setPassword('');
|
||||
setMnemonic(null);
|
||||
setError(null);
|
||||
setRevealed(false);
|
||||
setCountdown(AUTO_HIDE_SECONDS);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (clipboardTimerRef.current) {
|
||||
clearTimeout(clipboardTimerRef.current);
|
||||
clipboardTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearSensitiveData();
|
||||
onClose();
|
||||
}, [clearSensitiveData, onClose]);
|
||||
|
||||
// Auto-hide countdown
|
||||
useEffect(() => {
|
||||
if (!mnemonic) return;
|
||||
|
||||
setCountdown(AUTO_HIDE_SECONDS);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
handleClose();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [mnemonic, handleClose]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSensitiveData();
|
||||
};
|
||||
}, [clearSensitiveData]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleVerify = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Get vault data from backend
|
||||
const result = await walletApi.unlock();
|
||||
|
||||
// Attempt client-side decryption with password only
|
||||
const decrypted = await decryptVault(
|
||||
result.encryptedVault,
|
||||
result.vaultSalt,
|
||||
password,
|
||||
);
|
||||
|
||||
setMnemonic(decrypted);
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '';
|
||||
if (msg.includes('Too many attempts')) {
|
||||
setError('Too many attempts. Try again in 15 minutes.');
|
||||
} else {
|
||||
setError('Wrong password');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!mnemonic) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(mnemonic);
|
||||
|
||||
clipboardTimerRef.current = setTimeout(() => {
|
||||
try {
|
||||
void navigator.clipboard.writeText('');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, CLIPBOARD_CLEAR_SECONDS * 1000);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
const words = mnemonic?.split(' ') || [];
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={handleClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{mnemonic ? 'Seed Phrase' : 'Verify Identity'}
|
||||
</h3>
|
||||
<button onClick={handleClose} style={{ padding: '4px 8px', cursor: 'pointer' }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!mnemonic ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p style={{ color: '#666', fontSize: 13 }}>
|
||||
Enter your password to view your seed phrase.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Enter password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'red', fontSize: 13, margin: '0 0 12px' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleVerify}
|
||||
disabled={loading || password.length < 8}
|
||||
style={{
|
||||
...primaryButtonStyle,
|
||||
width: '100%',
|
||||
opacity: loading || password.length < 8 ? 0.5 : 1,
|
||||
cursor: loading || password.length < 8 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify & Show Seed Phrase'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<p style={{ color: '#b45309', fontSize: 13, margin: 0, fontWeight: 600 }}>
|
||||
Auto-hide in {countdown}s
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
style={navButtonStyle}
|
||||
>
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
</button>
|
||||
<button onClick={handleCopy} style={navButtonStyle}>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 8,
|
||||
}}>
|
||||
{words.map((word, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '6px 8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<span style={{ color: '#666', fontSize: 11 }}>{i + 1}.</span>{' '}
|
||||
<span style={{
|
||||
filter: revealed ? 'none' : 'blur(6px)',
|
||||
transition: 'filter 0.2s',
|
||||
userSelect: revealed ? 'text' : 'none',
|
||||
}}>
|
||||
{word}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p style={{ color: '#666', fontSize: 11, marginTop: 12, textAlign: 'center' }}>
|
||||
Clipboard will be cleared in {CLIPBOARD_CLEAR_SECONDS}s after copying.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Styles --
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
maxWidth: 480,
|
||||
width: '90%',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const navButtonStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '6px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const primaryButtonStyle: React.CSSProperties = {
|
||||
padding: '10px 16px',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
};
|
||||
@@ -1,13 +1,84 @@
|
||||
import { webEnv } from './env';
|
||||
|
||||
const API_URL = webEnv.apiUrl;
|
||||
const BITOK_BASE = process.env.NEXT_PUBLIC_BITOK_URL || 'http://localhost:8000';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// ── BITOK auth calls (httpOnly cookies + access_token in body) ──
|
||||
|
||||
async function bitokRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BITOK_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail || body.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const bitokAuth = {
|
||||
registrationStart: (email: string) =>
|
||||
bitokRequest<{ success: boolean }>('/v1/auth/registration/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
registrationComplete: (email: string, password: string, code: string) =>
|
||||
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/registration/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, code }),
|
||||
}),
|
||||
|
||||
loginStart: (email: string) =>
|
||||
bitokRequest<{ success: boolean }>('/v1/auth/login/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
|
||||
loginComplete: (email: string, password: string, code: string) =>
|
||||
bitokRequest<{ id: string; email: string; access_token: string }>('/v1/auth/login/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, code }),
|
||||
}),
|
||||
|
||||
refresh: () =>
|
||||
bitokRequest<{ result: boolean; access_token: string }>('/v1/jwt/refresh', { method: 'POST' }),
|
||||
|
||||
logout: () =>
|
||||
bitokRequest<{ ok: boolean }>('/v1/auth/logout', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// ── Wallet API calls (uses Bearer token from BITOK) ──
|
||||
|
||||
async function walletRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
@@ -23,6 +94,40 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getWallets: () => request<any>('/api/wallets'),
|
||||
export interface WalletSetupPayload {
|
||||
encryptedVault: string;
|
||||
vaultSalt: string;
|
||||
wallets: { chain: string; address: string; derivationPath: string }[];
|
||||
}
|
||||
|
||||
export interface WalletUnlockResponse {
|
||||
encryptedVault: string;
|
||||
vaultSalt: string;
|
||||
wallets: { chain: string; address: string; derivationPath: string }[];
|
||||
mnemonicShown: boolean;
|
||||
}
|
||||
|
||||
export const walletApi = {
|
||||
setup: (data: WalletSetupPayload) =>
|
||||
walletRequest<any>('/api/wallet/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
unlock: () =>
|
||||
walletRequest<WalletUnlockResponse>('/api/wallet/unlock'),
|
||||
|
||||
getWallets: () => walletRequest<any>('/api/wallets'),
|
||||
|
||||
confirmMnemonic: () =>
|
||||
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// ── Legacy api object (keep for components that still reference it) ──
|
||||
|
||||
export const api = {
|
||||
getWallets: () => walletRequest<any>('/api/wallets'),
|
||||
getVault: () => walletRequest<any>('/api/vault'),
|
||||
confirmMnemonic: () =>
|
||||
walletRequest<any>('/api/wallet/confirm-mnemonic', { method: 'POST' }),
|
||||
};
|
||||
|
||||
54
apps/web/src/lib/crypto/derive-keys.ts
Normal file
54
apps/web/src/lib/crypto/derive-keys.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { generateMnemonic, mnemonicToSeedBytes } from './mnemonic';
|
||||
import { deriveEthWallet } from './eth';
|
||||
import { deriveBtcWallet } from './btc';
|
||||
import { deriveSolWallet } from './sol';
|
||||
import { deriveTrxWallet } from './trx';
|
||||
|
||||
export type Chain = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
const DERIVATION_PATHS: Record<Chain, string> = {
|
||||
ETH: "m/44'/60'/0'/0/0",
|
||||
BTC: "m/84'/0'/0'/0/0",
|
||||
SOL: "m/44'/501'/0'/0'",
|
||||
TRX: "m/44'/195'/0'/0/0",
|
||||
BSC: "m/44'/60'/0'/0/0",
|
||||
};
|
||||
|
||||
export interface DerivedWallet {
|
||||
chain: Chain;
|
||||
address: string;
|
||||
privateKey: string;
|
||||
derivationPath: string;
|
||||
}
|
||||
|
||||
export interface DerivedKeys {
|
||||
mnemonic: string;
|
||||
wallets: DerivedWallet[];
|
||||
}
|
||||
|
||||
export async function generateWallets(): Promise<DerivedKeys> {
|
||||
const mnemonic = generateMnemonic();
|
||||
return deriveWalletsFromMnemonic(mnemonic);
|
||||
}
|
||||
|
||||
export async function deriveWalletsFromMnemonic(mnemonic: string): Promise<DerivedKeys> {
|
||||
const seed = await mnemonicToSeedBytes(mnemonic);
|
||||
|
||||
const eth = deriveEthWallet(mnemonic);
|
||||
const btc = deriveBtcWallet(seed);
|
||||
const sol = deriveSolWallet(seed);
|
||||
const trx = deriveTrxWallet(mnemonic);
|
||||
// BSC uses the same secp256k1 key as ETH (identical derivation path m/44'/60'/0'/0/0)
|
||||
const bsc = deriveEthWallet(mnemonic);
|
||||
|
||||
return {
|
||||
mnemonic,
|
||||
wallets: [
|
||||
{ chain: 'ETH', address: eth.address, privateKey: eth.privateKey, derivationPath: DERIVATION_PATHS.ETH },
|
||||
{ chain: 'BTC', address: btc.address, privateKey: btc.privateKey, derivationPath: DERIVATION_PATHS.BTC },
|
||||
{ chain: 'SOL', address: sol.address, privateKey: sol.privateKey, derivationPath: DERIVATION_PATHS.SOL },
|
||||
{ chain: 'TRX', address: trx.address, privateKey: trx.privateKey, derivationPath: DERIVATION_PATHS.TRX },
|
||||
{ chain: 'BSC', address: bsc.address, privateKey: bsc.privateKey, derivationPath: DERIVATION_PATHS.BSC },
|
||||
],
|
||||
};
|
||||
}
|
||||
107
apps/web/src/lib/crypto/vault.ts
Normal file
107
apps/web/src/lib/crypto/vault.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
export async function encryptVault(
|
||||
mnemonic: string,
|
||||
password: string,
|
||||
): Promise<{ encryptedVault: string; vaultSalt: string }> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
aesKey,
|
||||
new TextEncoder().encode(mnemonic)
|
||||
);
|
||||
|
||||
const blob = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||
blob.set(iv, 0);
|
||||
blob.set(new Uint8Array(ciphertext), iv.length);
|
||||
|
||||
return {
|
||||
encryptedVault: uint8ToBase64(blob),
|
||||
vaultSalt: uint8ToHex(salt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptVault(
|
||||
encryptedVault: string,
|
||||
vaultSalt: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const salt = hexToUint8(vaultSalt);
|
||||
const raw = base64ToUint8(encryptedVault);
|
||||
|
||||
const iv = raw.slice(0, 12);
|
||||
const ciphertextWithTag = raw.slice(12);
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const aesKey = await crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
aesKey,
|
||||
ciphertextWithTag
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
function uint8ToBase64(arr: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
binary += String.fromCharCode(arr[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToUint8(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const arr = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
arr[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function uint8ToHex(arr: Uint8Array): string {
|
||||
return Array.from(arr)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function hexToUint8(hex: string): Uint8Array {
|
||||
const arr = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
arr[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
@@ -1,48 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface Wallet {
|
||||
chain: string;
|
||||
address: string;
|
||||
derivationPath: string;
|
||||
}
|
||||
import { bitokAuth, walletApi, setAccessToken } from '@/lib/api';
|
||||
import { encryptVault, decryptVault } from '@/lib/crypto/vault';
|
||||
import { generateWallets, deriveWalletsFromMnemonic, type DerivedWallet } from '@/lib/crypto/derive-keys';
|
||||
|
||||
interface AuthState {
|
||||
user: { id: string; email: string } | null;
|
||||
wallets: Wallet[];
|
||||
wallets: DerivedWallet[];
|
||||
mnemonic: string | null;
|
||||
mnemonicShown: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
init: () => Promise<void>;
|
||||
// 2-step registration
|
||||
registerStart: (email: string) => Promise<void>;
|
||||
registerComplete: (email: string, password: string, code: string) => Promise<void>;
|
||||
|
||||
// 2-step login
|
||||
loginStart: (email: string) => Promise<void>;
|
||||
loginComplete: (email: string, password: string, code: string) => Promise<void>;
|
||||
|
||||
confirmMnemonic: () => Promise<void>;
|
||||
logout: () => void;
|
||||
clearMnemonic: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
wallets: [],
|
||||
mnemonic: null,
|
||||
mnemonicShown: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
init: async () => {
|
||||
registerStart: async (email) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const wallets = await api.getWallets();
|
||||
await bitokAuth.registrationStart(email);
|
||||
set({ loading: false });
|
||||
} catch (err: any) {
|
||||
set({ loading: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
registerComplete: async (email, password, code) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
// Step 1: Complete BITOK registration, get JWT
|
||||
const authData = await bitokAuth.registrationComplete(email, password, code);
|
||||
setAccessToken(authData.access_token);
|
||||
|
||||
// Step 2: Generate mnemonic & derive wallets
|
||||
const { mnemonic, wallets } = await generateWallets();
|
||||
|
||||
// Step 3: Encrypt vault with password only
|
||||
const { encryptedVault, vaultSalt } = await encryptVault(mnemonic, password);
|
||||
|
||||
// Step 4: Send wallet data to backend
|
||||
await walletApi.setup({
|
||||
encryptedVault,
|
||||
vaultSalt,
|
||||
wallets: wallets.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivationPath,
|
||||
})),
|
||||
});
|
||||
|
||||
set({
|
||||
user: { id: '', email: '' },
|
||||
user: { id: authData.id, email: authData.email },
|
||||
wallets,
|
||||
mnemonic,
|
||||
mnemonicShown: false,
|
||||
loading: false,
|
||||
});
|
||||
} catch {
|
||||
set({ user: null, wallets: [], loading: false });
|
||||
} catch (err: any) {
|
||||
set({ loading: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
loginStart: async (email) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await bitokAuth.loginStart(email);
|
||||
set({ loading: false });
|
||||
} catch (err: any) {
|
||||
set({ loading: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
loginComplete: async (email, password, code) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
// Step 1: Complete BITOK login, get JWT
|
||||
const authData = await bitokAuth.loginComplete(email, password, code);
|
||||
setAccessToken(authData.access_token);
|
||||
|
||||
// Step 2: Get vault data from wallet API
|
||||
const vaultData = await walletApi.unlock();
|
||||
|
||||
// Step 3: Decrypt vault client-side with password only
|
||||
const mnemonic = await decryptVault(vaultData.encryptedVault, vaultData.vaultSalt, password);
|
||||
const { wallets } = await deriveWalletsFromMnemonic(mnemonic);
|
||||
|
||||
set({
|
||||
user: { id: authData.id, email: authData.email },
|
||||
wallets,
|
||||
mnemonic: vaultData.mnemonicShown ? null : mnemonic,
|
||||
mnemonicShown: vaultData.mnemonicShown,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
set({ loading: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
confirmMnemonic: async () => {
|
||||
try {
|
||||
await walletApi.confirmMnemonic();
|
||||
set({ mnemonicShown: true, mnemonic: null });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({ user: null, wallets: [] });
|
||||
bitokAuth.logout().catch(() => {});
|
||||
setAccessToken(null);
|
||||
set({ user: null, wallets: [], mnemonic: null, mnemonicShown: true });
|
||||
},
|
||||
|
||||
clearMnemonic: () => set({ mnemonic: null }),
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user