update project
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user