Added recovery keys
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Recovery from './pages/Recovery';
|
||||
import Chat from './pages/Chat';
|
||||
import { usePlatform } from './platform';
|
||||
import { useSearch } from './contexts/SearchContext';
|
||||
@@ -37,6 +38,7 @@ function AuthGuard({ children }) {
|
||||
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
|
||||
sessionStorage.setItem('signingKey', savedSession.signingKey);
|
||||
sessionStorage.setItem('privateKey', savedSession.privateKey);
|
||||
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
|
||||
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
|
||||
searchCtx?.initialize();
|
||||
// Restore user preferences from file-based backup into localStorage
|
||||
@@ -70,7 +72,7 @@ function AuthGuard({ children }) {
|
||||
useEffect(() => {
|
||||
if (authState === 'loading') return;
|
||||
|
||||
const isAuthPage = location.pathname === '/' || location.pathname === '/register';
|
||||
const isAuthPage = location.pathname === '/' || location.pathname === '/register' || location.pathname === '/recovery';
|
||||
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
|
||||
|
||||
if (hasSession && isAuthPage) {
|
||||
@@ -105,6 +107,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/recovery" element={<Recovery />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
</Routes>
|
||||
</AuthGuard>
|
||||
|
||||
1
packages/shared/src/assets/icons/chat.svg
Normal file
1
packages/shared/src/assets/icons/chat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24"><defs><mask id="789f9254-36b7-47b8-a1e8-6f324310eb42"><rect fill="white" width="100%" height="100%"></rect></mask></defs><g mask="url(#789f9254-36b7-47b8-a1e8-6f324310eb42)"><svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M12 22a10 10 0 1 0-8.45-4.64c.13.19.11.44-.04.61l-2.06 2.37A1 1 0 0 0 2.2 22H12Z" class=""></path></svg></g></svg>
|
||||
|
After Width: | Height: | Size: 490 B |
1
packages/shared/src/assets/icons/eye.svg
Normal file
1
packages/shared/src/assets/icons/eye.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="viewersIcon_d6b206" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M15.56 11.77c.2-.1.44.02.44.23a4 4 0 1 1-4-4c.21 0 .33.25.23.44a2.5 2.5 0 0 0 3.32 3.32Z" class=""></path><path fill="currentColor" fill-rule="evenodd" d="M22.89 11.7c.07.2.07.4 0 .6C22.27 13.9 19.1 21 12 21c-7.11 0-10.27-7.11-10.89-8.7a.83.83 0 0 1 0-.6C1.73 10.1 4.9 3 12 3c7.11 0 10.27 7.11 10.89 8.7Zm-4.5-3.62A15.11 15.11 0 0 1 20.85 12c-.38.88-1.18 2.47-2.46 3.92C16.87 17.62 14.8 19 12 19c-2.8 0-4.87-1.38-6.39-3.08A15.11 15.11 0 0 1 3.15 12c.38-.88 1.18-2.47 2.46-3.92C7.13 6.38 9.2 5 12 5c2.8 0 4.87 1.38 6.39 3.08Z" clip-rule="evenodd" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 749 B |
1
packages/shared/src/assets/icons/fullscreen.svg
Normal file
1
packages/shared/src/assets/icons/fullscreen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M4 6c0-1.1.9-2 2-2h3a1 1 0 0 0 0-2H6a4 4 0 0 0-4 4v3a1 1 0 0 0 2 0V6ZM4 18c0 1.1.9 2 2 2h3a1 1 0 1 1 0 2H6a4 4 0 0 1-4-4v-3a1 1 0 1 1 2 0v3ZM18 4a2 2 0 0 1 2 2v3a1 1 0 1 0 2 0V6a4 4 0 0 0-4-4h-3a1 1 0 1 0 0 2h3ZM20 18a2 2 0 0 1-2 2h-3a1 1 0 1 0 0 2h3a4 4 0 0 0 4-4v-3a1 1 0 1 0-2 0v3Z" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 466 B |
1
packages/shared/src/assets/icons/popout.svg
Normal file
1
packages/shared/src/assets/icons/popout.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M15 2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4.41l-4.3 4.3a1 1 0 1 1-1.4-1.42L19.58 3H16a1 1 0 0 1-1-1Z" class=""></path><path fill="currentColor" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a1 1 0 1 0-2 0v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a1 1 0 1 0 0-2H5Z" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 475 B |
1
packages/shared/src/assets/icons/stop_sharing.svg
Normal file
1
packages/shared/src/assets/icons/stop_sharing.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
1
packages/shared/src/assets/icons/volume.svg
Normal file
1
packages/shared/src/assets/icons/volume.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="controlIcon_f1ceac" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="var(--interactive-icon-default)" d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.18 15.36c-.55.35-1.18-.12-1.18-.78v-.27c0-.36.2-.67.45-.93a2 2 0 0 0 0-2.76c-.24-.26-.45-.57-.45-.93v-.27c0-.66.63-1.13 1.18-.78a4 4 0 0 1 0 6.72Z" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 506 B |
@@ -538,6 +538,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const typingTimeoutRef = useRef(null);
|
||||
const lastTypingEmitRef = useRef(0);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const initialScrollScheduledRef = useRef(false);
|
||||
const decryptionDoneRef = useRef(false);
|
||||
const channelLoadIdRef = useRef(0);
|
||||
const jumpToMessageIdRef = useRef(null);
|
||||
@@ -559,6 +560,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
const prevMessageCountRef = useRef(0);
|
||||
const prevFirstMsgIdRef = useRef(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -576,6 +578,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
useEffect(() => {
|
||||
statusRef.current = status;
|
||||
loadMoreRef.current = loadMore;
|
||||
if (status !== 'LoadingMore') {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [status, loadMore]);
|
||||
|
||||
const typingData = useQuery(
|
||||
@@ -632,7 +637,21 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
});
|
||||
};
|
||||
|
||||
setDecryptedMessages(buildFromCache());
|
||||
const newMessages = buildFromCache();
|
||||
|
||||
// Adjust firstItemIndex atomically with data to prevent Virtuoso scroll jump
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const newCount = newMessages.length;
|
||||
if (newCount > prevCount && prevCount > 0) {
|
||||
if (prevFirstMsgIdRef.current && newMessages[0]?.id !== prevFirstMsgIdRef.current) {
|
||||
const prependedCount = newCount - prevCount;
|
||||
setFirstItemIndex(prev => prev - prependedCount);
|
||||
}
|
||||
}
|
||||
prevMessageCountRef.current = newCount;
|
||||
prevFirstMsgIdRef.current = newMessages[0]?.id || null;
|
||||
|
||||
setDecryptedMessages(newMessages);
|
||||
|
||||
// Phase 2: Batch-decrypt only uncached messages in background
|
||||
const processUncached = async () => {
|
||||
@@ -661,7 +680,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
decryptionDoneRef.current = true;
|
||||
setDecryptedMessages(buildFromCache());
|
||||
|
||||
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
initialScrollScheduledRef.current = true;
|
||||
const loadId = channelLoadIdRef.current;
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -670,6 +690,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -786,7 +807,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// After decryption, items may be taller — re-scroll to bottom.
|
||||
// Double-rAF waits for paint + ResizeObserver cycle; escalating timeouts are safety nets.
|
||||
if (isInitialLoadRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
if (isInitialLoadRef.current && !initialScrollScheduledRef.current && virtuosoRef.current && !jumpToMessageIdRef.current) {
|
||||
initialScrollScheduledRef.current = true;
|
||||
const loadId = channelLoadIdRef.current;
|
||||
const scrollEnd = () => { const el = scrollerElRef.current; if (el) el.scrollTop = el.scrollHeight; };
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -795,6 +817,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 300);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 800);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId && isInitialLoadRef.current) scrollEnd(); }, 1200);
|
||||
setTimeout(() => { if (channelLoadIdRef.current === loadId) isInitialLoadRef.current = false; }, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -825,6 +848,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
channelLoadIdRef.current += 1;
|
||||
setDecryptedMessages([]);
|
||||
isInitialLoadRef.current = true;
|
||||
initialScrollScheduledRef.current = false;
|
||||
decryptionDoneRef.current = false;
|
||||
pingSeededRef.current = false;
|
||||
notifiedMessageIdsRef.current = new Set();
|
||||
@@ -837,6 +861,7 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
setSlashQuery(null);
|
||||
setEphemeralMessages([]);
|
||||
floodAbortRef.current = true;
|
||||
isLoadingMoreRef.current = false;
|
||||
setFirstItemIndex(INITIAL_FIRST_INDEX);
|
||||
prevMessageCountRef.current = 0;
|
||||
prevFirstMsgIdRef.current = null;
|
||||
@@ -1016,24 +1041,13 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
|
||||
// Virtuoso: startReached replaces IntersectionObserver
|
||||
const handleStartReached = useCallback(() => {
|
||||
if (isLoadingMoreRef.current) return;
|
||||
if (statusRef.current === 'CanLoadMore') {
|
||||
isLoadingMoreRef.current = true;
|
||||
loadMoreRef.current(50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Virtuoso: firstItemIndex management for prepend without jitter
|
||||
useEffect(() => {
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const newCount = decryptedMessages.length;
|
||||
if (newCount > prevCount && prevCount > 0) {
|
||||
if (prevFirstMsgIdRef.current && decryptedMessages[0]?.id !== prevFirstMsgIdRef.current) {
|
||||
const prependedCount = newCount - prevCount;
|
||||
setFirstItemIndex(prev => prev - prependedCount);
|
||||
}
|
||||
}
|
||||
prevMessageCountRef.current = newCount;
|
||||
prevFirstMsgIdRef.current = decryptedMessages[0]?.id || null;
|
||||
}, [decryptedMessages]);
|
||||
|
||||
// Virtuoso: followOutput auto-scrolls on new messages and handles initial load
|
||||
const followOutput = useCallback((isAtBottom) => {
|
||||
@@ -1068,12 +1082,8 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
if (channelLoadIdRef.current === loadId) {
|
||||
isInitialLoadRef.current = false;
|
||||
}
|
||||
}, 1500);
|
||||
}, 300);
|
||||
}
|
||||
} else if (isInitialLoadRef.current && decryptionDoneRef.current) {
|
||||
// Content resize pushed us off bottom during initial load — snap back
|
||||
const el = scrollerElRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [markChannelAsRead]);
|
||||
|
||||
@@ -1668,26 +1678,9 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
|
||||
return (
|
||||
<>
|
||||
{status === 'LoadingMore' && (
|
||||
<>
|
||||
{[
|
||||
{ name: 80, lines: [260, 180] },
|
||||
{ name: 60, lines: [310] },
|
||||
{ name: 100, lines: [240, 140] },
|
||||
{ name: 70, lines: [290] },
|
||||
{ name: 90, lines: [200, 260] },
|
||||
{ name: 55, lines: [330] },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="skeleton-message" style={{ animationDelay: `${i * 0.1}s` }}>
|
||||
<div className="skeleton-avatar" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="skeleton-name" style={{ width: s.name }} />
|
||||
{s.lines.map((w, j) => (
|
||||
<div key={j} className="skeleton-line" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||
<div className="loading-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px' }} />
|
||||
</div>
|
||||
)}
|
||||
{status === 'Exhausted' && (decryptedMessages.length > 0 || rawMessages.length === 0) && (
|
||||
<div className="channel-beginning">
|
||||
|
||||
@@ -17,6 +17,7 @@ const THEME_PREVIEWS = {
|
||||
|
||||
const TABS = [
|
||||
{ id: 'account', label: 'My Account', section: 'USER SETTINGS' },
|
||||
{ id: 'security', label: 'Security', section: 'USER SETTINGS' },
|
||||
{ id: 'appearance', label: 'Appearance', section: 'USER SETTINGS' },
|
||||
{ id: 'voice', label: 'Voice & Video', section: 'APP SETTINGS' },
|
||||
{ id: 'keybinds', label: 'Keybinds', section: 'APP SETTINGS' },
|
||||
@@ -111,6 +112,7 @@ const UserSettings = ({ onClose, userId, username, onLogout }) => {
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
|
||||
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
|
||||
{activeTab === 'account' && <MyAccountTab userId={userId} username={username} />}
|
||||
{activeTab === 'security' && <SecurityTab />}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
{activeTab === 'voice' && <VoiceVideoTab />}
|
||||
{activeTab === 'keybinds' && <KeybindsTab />}
|
||||
@@ -550,6 +552,173 @@ const MyAccountTab = ({ userId, username }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/* =========================================
|
||||
SECURITY TAB
|
||||
========================================= */
|
||||
const SecurityTab = () => {
|
||||
const [masterKey, setMasterKey] = useState(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mk = sessionStorage.getItem('masterKey');
|
||||
setMasterKey(mk);
|
||||
}, []);
|
||||
|
||||
const formatKey = (hex) => {
|
||||
if (!hex) return '';
|
||||
return hex.match(/.{1,4}/g).join(' ');
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!masterKey) return;
|
||||
navigator.clipboard.writeText(masterKey).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!masterKey) return;
|
||||
const content = [
|
||||
'=== RECOVERY KEY ===',
|
||||
'',
|
||||
'This is your Recovery Key for your encrypted account.',
|
||||
'Store it in a safe place. If you lose your password, this key is the ONLY way to recover your account.',
|
||||
'',
|
||||
'DO NOT share this key with anyone.',
|
||||
'',
|
||||
`Recovery Key: ${masterKey}`,
|
||||
'',
|
||||
`Exported: ${new Date().toISOString()}`,
|
||||
].join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'recovery-key.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
display: 'block', color: 'var(--header-secondary)', fontSize: '12px',
|
||||
fontWeight: '700', textTransform: 'uppercase', marginBottom: '8px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 20px', fontSize: '20px' }}>Security</h2>
|
||||
|
||||
<div style={{ backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', padding: '20px' }}>
|
||||
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 8px', fontSize: '16px', fontWeight: '600' }}>
|
||||
Recovery Key
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '14px', margin: '0 0 16px', lineHeight: '1.4' }}>
|
||||
Your Recovery Key allows you to reset your password without losing access to your encrypted messages.
|
||||
Store it somewhere safe — if you forget your password, this is the <strong style={{ color: 'var(--text-normal)' }}>only way</strong> to recover your account.
|
||||
</p>
|
||||
|
||||
{!masterKey ? (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
|
||||
color: 'var(--text-muted)', fontSize: '14px',
|
||||
}}>
|
||||
Recovery Key is not available in this session. Please log out and log back in to access it.
|
||||
</div>
|
||||
) : !revealed ? (
|
||||
<div>
|
||||
{/* Warning box */}
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(250, 166, 26, 0.1)', border: '1px solid rgba(250, 166, 26, 0.4)',
|
||||
borderRadius: '4px', padding: '12px', marginBottom: '16px',
|
||||
}}>
|
||||
<div style={{ color: '#faa61a', fontSize: '14px', fontWeight: '600', marginBottom: '4px' }}>
|
||||
Warning
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-normal)', fontSize: '13px', lineHeight: '1.4' }}>
|
||||
Anyone with your Recovery Key can reset your password and take control of your account.
|
||||
Only reveal it in a private, secure environment.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{
|
||||
display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer',
|
||||
color: 'var(--text-normal)', fontSize: '14px', marginBottom: '16px',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
style={{ width: '16px', height: '16px', accentColor: 'var(--brand-experiment)' }}
|
||||
/>
|
||||
I understand and want to reveal my Recovery Key
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setRevealed(true)}
|
||||
disabled={!confirmed}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '10px 20px', cursor: confirmed ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px', fontWeight: '500', opacity: confirmed ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Reveal Recovery Key
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label style={labelStyle}>Your Recovery Key</label>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-tertiary)', borderRadius: '4px', padding: '16px',
|
||||
fontFamily: 'Consolas, "Courier New", monospace', fontSize: '15px',
|
||||
color: 'var(--text-normal)', wordBreak: 'break-all', lineHeight: '1.6',
|
||||
letterSpacing: '1px', marginBottom: '12px', userSelect: 'all',
|
||||
}}>
|
||||
{formatKey(masterKey)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
|
||||
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-normal)', border: 'none',
|
||||
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRevealed(false); setConfirmed(false); }}
|
||||
style={{
|
||||
backgroundColor: 'transparent', color: 'var(--text-muted)', border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px', padding: '8px 16px', cursor: 'pointer',
|
||||
fontSize: '14px', fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* =========================================
|
||||
APPEARANCE TAB
|
||||
========================================= */
|
||||
|
||||
@@ -2186,7 +2186,7 @@ body {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--app-frame-border);
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
@@ -2205,6 +2205,9 @@ body {
|
||||
border-radius: 4px;
|
||||
padding: 4px 4px;
|
||||
transition: background-color 0.1s;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.server-header-name:hover {
|
||||
|
||||
@@ -55,6 +55,7 @@ const Login = () => {
|
||||
|
||||
console.log('Decrypting Master Key...');
|
||||
const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
|
||||
sessionStorage.setItem('masterKey', mkHex);
|
||||
|
||||
console.log('Decrypting Private Keys...');
|
||||
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
|
||||
@@ -80,6 +81,7 @@ const Login = () => {
|
||||
publicKey: verifyData.publicKey || '',
|
||||
signingKey,
|
||||
privateKey: rsaPriv,
|
||||
masterKey: mkHex,
|
||||
searchDbKey: searchKeys.dak,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
@@ -136,6 +138,11 @@ const Login = () => {
|
||||
<div className="auth-footer">
|
||||
Need an account? <Link to="/register">Register</Link>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginTop: '8px' }}>
|
||||
<Link to="/recovery" style={{ color: 'var(--brand-experiment)', fontSize: '14px' }}>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
237
packages/shared/src/pages/Recovery.jsx
Normal file
237
packages/shared/src/pages/Recovery.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { usePlatform } from '../platform';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
const Recovery = () => {
|
||||
const [step, setStep] = useState(1); // 1 = verify key, 2 = set new password
|
||||
const [username, setUsername] = useState('');
|
||||
const [recoveryKey, setRecoveryKey] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Stored after step 1 verification
|
||||
const [verifiedMK, setVerifiedMK] = useState(null);
|
||||
const [recoveredSigningKey, setRecoveredSigningKey] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
const { crypto } = usePlatform();
|
||||
|
||||
async function decryptEncryptedField(encryptedJson, keyHex) {
|
||||
const obj = JSON.parse(encryptedJson);
|
||||
return crypto.decryptData(obj.content, keyHex, obj.iv, obj.tag);
|
||||
}
|
||||
|
||||
const handleVerify = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Clean input: strip whitespace, dashes, non-hex chars
|
||||
const cleanKey = recoveryKey.replace(/[\s\-]/g, '').toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(cleanKey)) {
|
||||
throw new Error('Invalid Recovery Key format. Must be 64 hex characters.');
|
||||
}
|
||||
|
||||
const data = await convex.query(api.auth.getRecoveryData, { username });
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Try decrypting Ed25519 private key with provided MK
|
||||
const encryptedPrivateKeysObj = JSON.parse(data.encryptedPrivateKeys);
|
||||
let signingKey;
|
||||
try {
|
||||
signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), cleanKey);
|
||||
} catch {
|
||||
throw new Error('Invalid Recovery Key. Decryption failed.');
|
||||
}
|
||||
|
||||
// Store for step 2
|
||||
setVerifiedMK(cleanKey);
|
||||
setRecoveredSigningKey(signingKey);
|
||||
setStep(2);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 1) {
|
||||
setError('Password cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Generate new salt and derive new keys
|
||||
const newSalt = await crypto.randomBytes(16);
|
||||
const { dek, dak } = await crypto.deriveAuthKeys(newPassword, newSalt);
|
||||
|
||||
// Re-encrypt master key with new DEK
|
||||
const newEncryptedMK = JSON.stringify(await crypto.encryptData(verifiedMK, dek));
|
||||
|
||||
// Hash new DAK
|
||||
const newHAK = await crypto.sha256(dak);
|
||||
|
||||
// Sign the reset message
|
||||
const timestamp = Date.now();
|
||||
const message = `password-reset:${username}:${timestamp}`;
|
||||
const signature = await crypto.signMessage(recoveredSigningKey, message);
|
||||
|
||||
// Call the server action
|
||||
const result = await convex.action(api.recovery.resetPasswordAction, {
|
||||
username,
|
||||
newSalt,
|
||||
newEncryptedMK,
|
||||
newHAK,
|
||||
signature,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
<div className="auth-header">
|
||||
<h2>Password Reset Complete</h2>
|
||||
<p>Your password has been changed successfully.</p>
|
||||
</div>
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(59, 165, 92, 0.1)', border: '1px solid rgba(59, 165, 92, 0.4)',
|
||||
borderRadius: '4px', padding: '12px', marginBottom: '16px', textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ color: '#3ba55c', fontSize: '14px' }}>
|
||||
You can now log in with your new password. All your encrypted messages remain accessible.
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/" className="auth-button" style={{
|
||||
display: 'block', textAlign: 'center', textDecoration: 'none',
|
||||
}}>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
<div className="auth-header">
|
||||
<h2>Account Recovery</h2>
|
||||
<p>{step === 1
|
||||
? 'Enter your username and Recovery Key to verify your identity.'
|
||||
: 'Set a new password for your account.'
|
||||
}</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#ed4245', marginBottom: 10, textAlign: 'center', fontSize: '14px' }}>{error}</div>}
|
||||
|
||||
{step === 1 ? (
|
||||
<form onSubmit={handleVerify}>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Recovery Key</label>
|
||||
<textarea
|
||||
value={recoveryKey}
|
||||
onChange={(e) => setRecoveryKey(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="Paste your 64-character Recovery Key"
|
||||
rows={3}
|
||||
style={{
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? 'Verifying...' : 'Verify Recovery Key'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleReset}>
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(59, 165, 92, 0.1)', border: '1px solid rgba(59, 165, 92, 0.4)',
|
||||
borderRadius: '4px', padding: '10px', marginBottom: '16px', textAlign: 'center',
|
||||
}}>
|
||||
<span style={{ color: '#3ba55c', fontSize: '14px' }}>
|
||||
Recovery Key verified for <strong>{username}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="auth-button" disabled={loading}>
|
||||
{loading ? 'Resetting Password...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="auth-footer">
|
||||
Remember your password? <Link to="/">Log In</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Recovery;
|
||||
Reference in New Issue
Block a user