Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import {
ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED,
ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK,
ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS,
ROUTE_NOTIFICATION_PREFERENCES,
ROUTE_NOTIFICATION_PREFERENCES, ROUTE_ENCRYPTION,
DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META,
} from './config/routes';
import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog } from 'lucide-react';
import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog, Lock } from 'lucide-react';
import { activateDemo, deactivateDemo } from './lib/demo-utils';
import { useNotificationPreferences } from './context/NotificationPreferencesContext';

Expand All @@ -47,6 +47,7 @@ const AdminDashboard = lazy(() => import('./components/AdminDashboard'));
const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard'));
const RefundManager = lazy(() => import('./components/RefundManager'));
const NotificationPreferencesPage = lazy(() => import('./components/NotificationPreferences'));
const EncryptionSettings = lazy(() => import('./components/EncryptionSettings'));

function App() {
const [userData, setUserData] = useState(null);
Expand Down Expand Up @@ -175,6 +176,7 @@ function App() {
{ path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan },
{ path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw },
{ path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog },
{ path: ROUTE_ENCRYPTION, label: 'Encryption', icon: Lock },
{ path: ROUTE_STATS, label: 'Stats', icon: BarChart3 },
];

Expand Down Expand Up @@ -421,6 +423,20 @@ function App() {
)
}
/>

{/* Encryption settings */}
<Route
path={ROUTE_ENCRYPTION}
element={
userData || demoEnabled ? (
<EncryptionSettings addToast={addToast} />
) : (
<RequireAuth onAuth={handleAuth} authLoading={authLoading} route={ROUTE_ENCRYPTION}>
<EncryptionSettings addToast={addToast} />
</RequireAuth>
)
}
/>

{/* Root and fallback */}
<Route path="/" element={<Navigate to={userData || demoEnabled ? DEFAULT_AUTHENTICATED_ROUTE : ROUTE_FEED} replace />} />
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/components/EncryptionSettings.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useState } from 'react';
import { useEncryption } from '../hooks/useEncryption';
import { useSenderAddress } from '../hooks/useSenderAddress';
import { Lock, RefreshCw, Copy, Check } from 'lucide-react';

export default function EncryptionSettings({ addToast }) {
const senderAddress = useSenderAddress();
const { keys, loading, error, regenerateKeys } = useEncryption(senderAddress);
const [copied, setCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);

const handleCopyPublicKey = async () => {
if (!keys?.publicKey) return;

try {
await navigator.clipboard.writeText(keys.publicKey);
setCopied(true);
addToast('Public key copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000);
} catch (err) {

Check failure on line 20 in frontend/src/components/EncryptionSettings.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'err' is defined but never used
addToast('Failed to copy public key', 'error');
}
};

const handleRegenerateKeys = async () => {
if (!confirm('Are you sure you want to regenerate your encryption keys? You will not be able to decrypt old messages.')) {
return;
}

setRegenerating(true);
try {
await regenerateKeys();
addToast('Encryption keys regenerated successfully', 'success');
} catch (err) {

Check failure on line 34 in frontend/src/components/EncryptionSettings.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'err' is defined but never used
addToast('Failed to regenerate keys', 'error');
} finally {
setRegenerating(false);
}
};

if (!senderAddress) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
<p className="text-gray-500 dark:text-gray-400 text-center">
Connect your wallet to manage encryption settings
</p>
</div>
</div>
);
}

if (loading) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gray-300 dark:border-gray-600 border-t-gray-900 dark:border-t-white" />
</div>
</div>
</div>
);
}

if (error) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-red-200 dark:border-red-800 p-6">
<p className="text-red-500 text-center">{error}</p>
</div>
</div>
);
}

return (
<div className="max-w-2xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<Lock className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Encryption Settings</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Manage your message encryption keys</p>
</div>
</div>

<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-300">
Your encryption keys are stored locally in your browser. They are used to encrypt and decrypt private tip messages.
</p>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Your Public Key
</label>
<div className="flex gap-2">
<div className="flex-1 px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 font-mono text-xs break-all text-gray-700 dark:text-gray-300">
{keys?.publicKey ? keys.publicKey.slice(0, 100) + '...' : 'No key available'}
</div>
<button
onClick={handleCopyPublicKey}
disabled={!keys?.publicKey}
className="px-4 py-3 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Copy public key"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Share this key with others so they can send you encrypted messages
</p>
</div>

<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Private Key Status
</label>
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${keys?.privateKey ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-gray-700 dark:text-gray-300">
{keys?.privateKey ? 'Private key is stored securely' : 'No private key available'}
</span>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Your private key never leaves your browser and is used to decrypt messages sent to you
</p>
</div>

<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleRegenerateKeys}
disabled={regenerating}
className="flex items-center gap-2 px-4 py-2.5 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-700 dark:text-red-400 rounded-xl font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${regenerating ? 'animate-spin' : ''}`} />
{regenerating ? 'Regenerating...' : 'Regenerate Keys'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Warning: Regenerating keys will prevent you from decrypting old messages
</p>
</div>
</div>
</div>
</div>
);
}
76 changes: 68 additions & 8 deletions frontend/src/components/SendTip.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
import { useSenderAddress } from '../hooks/useSenderAddress';
import { useContractFee } from '../hooks/useContractFee';
import { useTransactionFeeEstimate } from '../hooks/useTransactionFeeEstimate';
import { useEncryption } from '../hooks/useEncryption';
import { analytics } from '../lib/analytics';
import ConfirmDialog from './ui/confirm-dialog';
import TxStatus from './ui/tx-status';
import AddressBook from './AddressBook';
import { Lock, Unlock } from 'lucide-react';

const MIN_TIP_STX = 0.001; // minimum tip in STX
const MAX_TIP_STX = 10000; // maximum tip in STX
Expand Down Expand Up @@ -67,10 +69,12 @@
const [amountError, setAmountError] = useState('');
const [cooldown, setCooldown] = useState(0);
const [showAddressBook, setShowAddressBook] = useState(false);
const [encryptMessage, setEncryptMessage] = useState(false);
const cooldownRef = useRef(null);

const walletSenderAddress = useSenderAddress();
const senderAddress = demoEnabled ? getDemoData().mockWalletAddress : walletSenderAddress;
const { encrypt, canEncryptFor } = useEncryption(senderAddress);
const realBalanceState = useBalance(senderAddress);
const { displayBalance: balance, sendTipInDemo, pendingTransaction } = useSendTipWithDemo(realBalanceState.balance);
const balanceLoading = demoEnabled ? false : realBalanceState.loading;
Expand All @@ -88,11 +92,12 @@
highFeeWarning,
feeLevel,
setFeeLevel,
speedEstimates,

Check failure on line 95 in frontend/src/components/SendTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'speedEstimates' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
refresh: refreshTransactionFee,

Check failure on line 96 in frontend/src/components/SendTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'refreshTransactionFee' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]/u
} = useTransactionFeeEstimate();

const isRecipientHighRisk = !canProceedWithRecipient(recipient, blockedWarning);
const canEncrypt = recipient && canEncryptFor(recipient);

useEffect(() => {
return () => {
Expand Down Expand Up @@ -205,13 +210,25 @@
setLoading(true);

try {
let finalMessage = message || 'Thanks!';

if (encryptMessage && message && canEncrypt) {
try {
finalMessage = await encrypt(message, recipient.trim());
} catch (encryptError) {

Check failure on line 218 in frontend/src/components/SendTip.jsx

View workflow job for this annotation

GitHub Actions / Frontend Lint

'encryptError' is defined but never used
addToast('Failed to encrypt message. Sending unencrypted.', 'warning');
finalMessage = message;
}
}

if (demoEnabled) {
const result = await sendTipInDemo(recipient.trim(), amount, message, category);
const result = await sendTipInDemo(recipient.trim(), amount, finalMessage, category);
setPendingTx(result);
setRecipient('');
setAmount('');
setMessage('');
setCategory(0);
setEncryptMessage(false);
startCooldown();
addToast('Demo tip sent successfully.', 'success');
setLoading(false);
Expand All @@ -226,7 +243,7 @@
const functionArgs = [
principalCV(recipient.trim()),
uintCV(microSTX),
stringUtf8CV(message || 'Thanks!'),
stringUtf8CV(finalMessage),
uintCV(category)
];

Expand All @@ -246,6 +263,7 @@
setAmount('');
setMessage('');
setCategory(0);
setEncryptMessage(false);
notifyTipSent();
startCooldown();
analytics.trackTipConfirmed();
Expand All @@ -262,7 +280,6 @@
console.error('Failed to send tip:', msg);
analytics.trackTipFailed();

// Provide a more specific message for post-condition failures
if (msg.toLowerCase().includes('post-condition') || msg.toLowerCase().includes('postcondition')) {
addToast('Transaction rejected by post-condition check. Your funds are safe.', 'error');
} else {
Expand Down Expand Up @@ -360,13 +377,46 @@

{/* Message */}
<div>
<label htmlFor="tip-message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">Message (optional)</label>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="tip-message" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Message (optional)</label>
{message && (
<button
type="button"
onClick={() => setEncryptMessage(!encryptMessage)}
disabled={!canEncrypt}
className={`flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium transition-all ${
encryptMessage
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
} ${!canEncrypt ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}`}
title={!canEncrypt ? 'Recipient public key not available' : encryptMessage ? 'Message will be encrypted' : 'Click to encrypt message'}
>
{encryptMessage ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
{encryptMessage ? 'Encrypted' : 'Encrypt'}
</button>
)}
</div>
<textarea id="tip-message" value={message} onChange={(e) => setMessage(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white rounded-xl text-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 outline-none transition-all resize-none"
placeholder="Great work!" maxLength={280} rows={2} />
<p className={`text-xs mt-1 text-right ${message.length >= 280 ? 'text-red-500' : 'text-gray-400'}`}>
{message.length}/280
</p>
<div className="flex items-center justify-between mt-1">
<div>
{encryptMessage && canEncrypt && (
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<Lock className="w-3 h-3" />
Only recipient can read this message
</p>
)}
{encryptMessage && !canEncrypt && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Recipient key unavailable, will send unencrypted
</p>
)}
</div>
<p className={`text-xs ${message.length >= 280 ? 'text-red-500' : 'text-gray-400'}`}>
{message.length}/280
</p>
</div>
</div>

{/* Category */}
Expand Down Expand Up @@ -489,7 +539,17 @@
<p>Send <strong>{amount} STX</strong> to:</p>
<p className="font-mono text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded-lg break-all">{recipient}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Category: <strong>{TIP_CATEGORIES.find(c => c.id === category)?.label}</strong></p>
{message && <p className="italic text-gray-500">"{message}"</p>}
{message && (
<div>
<p className="italic text-gray-500">"{message}"</p>
{encryptMessage && canEncrypt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1 flex items-center gap-1">
<Lock className="w-3 h-3" />
Message will be encrypted
</p>
)}
</div>
)}
{amount && parseFloat(amount) > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex justify-between">
Expand Down
Loading
Loading