Merge pull request #5699 from nymtech/fix/sign-in-page-wallet

Allow copy and paste on logins fields for the wallet
This commit is contained in:
Tommy Verrall
2025-04-09 15:15:28 +01:00
committed by GitHub
12 changed files with 422 additions and 28 deletions
@@ -32,6 +32,7 @@
"updater:allow-download",
"updater:allow-download-and-install",
"updater:allow-install",
"core:event:allow-listen"
"core:event:allow-listen",
"shell:allow-open"
]
}
@@ -1 +1 @@
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen"],"platforms":["linux","macOS","windows"]}}
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open"],"platforms":["linux","macOS","windows"]}}
@@ -57,6 +57,7 @@ const FormContextProvider = ({ children }: { children: React.ReactNode }) => {
const [signature, setSignature] = useState('');
const onError = (e: string) => {
// eslint-disable-next-line no-console
console.error(e);
};
@@ -21,6 +21,7 @@ export const SignMessageModal = ({ onClose }: { onClose: () => void }) => {
const sig = await signMessage(message);
setSignature(sig || '');
} catch (err) {
// eslint-disable-next-line no-console
console.error('Signing failed:', err);
setSignature('');
}
@@ -105,6 +105,7 @@ export const CurrencyFormFieldWithPaste = ({
processPastedText(clipboardText);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Error accessing clipboard:', err);
}
}
+160 -20
View File
@@ -1,7 +1,18 @@
import React, { FC, useEffect, useRef, useState } from 'react';
import type { UnlistenFn } from '@tauri-apps/api/event';
import { listen } from '@tauri-apps/api/event';
import { Box, Paper, Chip, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
import {
Box,
Paper,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
useTheme,
} from '@mui/material';
// see https://github.com/tauri-apps/tauri-plugin-log/blob/dev/webview-src/index.ts#L4
enum LogLevel {
@@ -29,24 +40,75 @@ const getLogLevelName = (value: LogLevel) => {
}
};
const getLogLevelColor = (level: LogLevel, theme: any) => {
switch (level) {
case LogLevel.Trace:
return {
bg: '#e8f4f8',
color: '#2c3e50',
chipBg: '#e8f4f8',
};
case LogLevel.Debug:
return {
bg: '#e8f0f8',
color: '#2c3e50',
chipBg: '#e8f0f8',
};
case LogLevel.Info:
return {
bg: '#e8f8e8',
color: '#2c3e50',
chipBg: '#e8f8e8',
};
case LogLevel.Warn:
return {
bg: '#fff8e0',
color: '#5d4037',
chipBg: '#fff8e0',
};
case LogLevel.Error:
return {
bg: '#ffe8e8',
color: '#c0392b',
chipBg: '#ffe8e8',
};
default:
return {
bg: theme.palette.mode === 'dark' ? '#333' : '#f0f0f0',
color: theme.palette.mode === 'dark' ? '#fff' : '#000',
chipBg: theme.palette.mode === 'dark' ? '#444' : '#e0e0e0',
};
}
};
// see https://github.com/tauri-apps/tauri-plugin-log/blob/dev/webview-src/index.ts#L147
interface RecordPayload {
level: LogLevel;
message: string;
timestamp?: number; // Adding timestamp for unique key generation
}
export const LogViewer: FC = () => {
const theme = useTheme();
const unlisten = useRef<UnlistenFn>();
const messages = useRef<RecordPayload[]>([]);
const [messages, setMessages] = useState<RecordPayload[]>([]);
const [messageCount, setMessageCount] = useState(0);
const tableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listen('log://log', (event) => {
// eslint-disable-next-line no-console
console.log(event.payload);
const payload = event.payload as RecordPayload;
messages.current.unshift(payload);
const payloadWithTimestamp = {
...payload,
timestamp: Date.now(),
};
setMessages((prev) => [payloadWithTimestamp, ...prev]);
setMessageCount((prev) => prev + 1);
if (tableRef.current) {
tableRef.current.scrollTop = 0;
}
}).then((fn) => {
unlisten.current = fn;
});
@@ -59,25 +121,102 @@ export const LogViewer: FC = () => {
}, []);
return (
<Box sx={{ height: '100vh', width: '100vw', display: 'grid', gridTemplateRows: '1fr auto' }}>
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'grid',
gridTemplateRows: '1fr auto',
bgcolor: theme.palette.mode === 'dark' ? '#1e1e1e' : '#f5f5f5',
color: theme.palette.mode === 'dark' ? '#ffffff' : '#000000',
}}
>
<Box sx={{ overflowY: 'hidden', p: 2 }}>
<TableContainer component={Paper} sx={{ maxHeight: '100%' }}>
<TableContainer
component={Paper}
sx={{
maxHeight: '100%',
bgcolor: '#ffffff',
boxShadow: theme.shadows[3],
borderRadius: '4px',
}}
ref={tableRef}
>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Severity</TableCell>
<TableCell>Log message</TableCell>
<TableCell
sx={{
bgcolor: '#f0f0f0',
color: '#333333',
fontWeight: 'bold',
width: '120px',
borderBottom: '1px solid #e0e0e0',
}}
>
Severity
</TableCell>
<TableCell
sx={{
bgcolor: '#f0f0f0',
color: '#333333',
fontWeight: 'bold',
borderBottom: '1px solid #e0e0e0',
}}
>
Log message
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{messages.current.map((m) => (
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell sx={{ padding: 1 }}>
<Chip label={getLogLevelName(m.level)} variant="outlined" size="small" />
</TableCell>
<TableCell sx={{ padding: 1, fontFamily: 'Monospace' }}>{m.message}</TableCell>
</TableRow>
))}
{messages.map((m, index) => {
const levelColors = getLogLevelColor(m.level, theme);
return (
<TableRow
key={`log-${m.timestamp || index}`}
sx={{
bgcolor: levelColors.bg,
'&:hover': {
filter: 'brightness(0.95)',
},
}}
>
<TableCell
sx={{
padding: 1,
borderBottom: `1px solid ${theme.palette.divider}`,
width: '120px',
bgcolor: 'transparent',
}}
>
<Chip
label={getLogLevelName(m.level)}
variant="filled"
size="small"
sx={{
bgcolor: levelColors.chipBg,
color: levelColors.color,
fontWeight: 'medium',
minWidth: '70px',
border: '1px solid rgba(0,0,0,0.1)',
}}
/>
</TableCell>
<TableCell
sx={{
padding: 1,
fontFamily: 'monospace',
fontSize: '0.875rem',
borderBottom: `1px solid ${theme.palette.divider}`,
color: levelColors.color,
bgcolor: 'transparent',
}}
>
{m.message}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
@@ -86,9 +225,10 @@ export const LogViewer: FC = () => {
sx={{
p: 1,
textAlign: 'right',
fontSize: 'small',
borderTop: '2px solid',
borderTopColor: (theme) => theme.palette.divider,
fontSize: '0.75rem',
borderTop: '1px solid #e0e0e0',
bgcolor: '#ffffff',
color: '#666666',
}}
>
{messageCount} log entries since opening this window
@@ -0,0 +1,119 @@
import React, { useRef, useEffect } from 'react';
import { Box } from '@mui/material';
import { PasswordInput } from '@nymproject/react/textfields/Password';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
interface PasteButtonProps {
onPaste: () => void;
}
const PasteButton: React.FC<PasteButtonProps> = ({ onPaste }) => (
<Tooltip title="Paste from clipboard">
<IconButton size="small" onClick={onPaste} aria-label="paste from clipboard">
<ContentPasteIcon fontSize="small" />
</IconButton>
</Tooltip>
);
interface EnhancedPasswordInputProps {
password: string;
onUpdatePassword: (password: string) => void;
label?: string;
placeholder?: string;
error?: string;
autoFocus?: boolean;
disabled?: boolean;
[key: string]: any;
}
export const EnhancedPasswordInput: React.FC<EnhancedPasswordInputProps> = ({
password,
onUpdatePassword,
...otherProps
}) => {
const inputRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const findInputElement = () => {
if (!inputRef.current) return undefined;
const input = inputRef.current.querySelector('input');
if (!input) return undefined;
const handleKeyDown = async (e: KeyboardEvent) => {
if (document.activeElement !== input) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
e.preventDefault();
setTimeout(() => {
(input as HTMLInputElement).select();
}, 0);
}
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
e.preventDefault();
try {
const clipboardText = await readText();
if (clipboardText) {
onUpdatePassword(clipboardText);
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste text:', err);
}
}
};
input.addEventListener('keydown', handleKeyDown);
return () => {
input.removeEventListener('keydown', handleKeyDown);
};
};
const cleanup = findInputElement();
const timeoutId = setTimeout(findInputElement, 100);
return () => {
if (cleanup) cleanup();
clearTimeout(timeoutId);
};
}, [onUpdatePassword]);
const handlePaste = async () => {
try {
const clipboardText = await readText();
if (clipboardText) {
onUpdatePassword(clipboardText);
const input = inputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste from clipboard:', err);
}
};
return (
<Box position="relative" ref={inputRef}>
<PasswordInput password={password} onUpdatePassword={onUpdatePassword} {...otherProps} />
<Box
sx={{
position: 'absolute',
right: '40px',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1,
}}
>
<PasteButton onPaste={handlePaste} />
</Box>
</Box>
);
};
@@ -0,0 +1,125 @@
import React, { useRef, useEffect } from 'react';
import { Box } from '@mui/material';
import { MnemonicInput as OriginalMnemonicInput } from '@nymproject/react/textfields/Mnemonic';
import { readText } from '@tauri-apps/plugin-clipboard-manager';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
interface PasteButtonProps {
onPaste: () => void;
}
const PasteButton: React.FC<PasteButtonProps> = ({ onPaste }) => (
<Tooltip title="Paste from clipboard">
<IconButton size="small" onClick={onPaste} aria-label="paste from clipboard">
<ContentPasteIcon fontSize="small" />
</IconButton>
</Tooltip>
);
interface EnhancedMnemonicInputProps {
mnemonic: string;
onUpdateMnemonic: (mnemonic: string) => void;
error?: string;
[key: string]: any;
}
export { OriginalMnemonicInput as MnemonicInput };
export const EnhancedMnemonicInput: React.FC<EnhancedMnemonicInputProps> = ({
mnemonic,
onUpdateMnemonic,
...otherProps
}) => {
const inputRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const findInputElement = () => {
if (!inputRef.current) return undefined;
const textarea = inputRef.current.querySelector('textarea');
const input = textarea || inputRef.current.querySelector('input');
if (!input) return undefined;
// Fix the event type issue by casting Event to KeyboardEvent
const handleKeyDown = async (e: Event) => {
const keyEvent = e as KeyboardEvent;
if (document.activeElement !== input) return;
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'a') {
keyEvent.preventDefault();
setTimeout(() => {
if (textarea) {
textarea.select();
} else {
(input as HTMLInputElement).select();
}
}, 0);
}
if ((keyEvent.metaKey || keyEvent.ctrlKey) && keyEvent.key === 'v') {
keyEvent.preventDefault();
try {
const clipboardText = await readText();
if (clipboardText) {
onUpdateMnemonic(clipboardText.trim());
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste text:', err);
}
}
};
input.addEventListener('keydown', handleKeyDown);
return () => {
input.removeEventListener('keydown', handleKeyDown);
};
};
const cleanup = findInputElement();
const timeoutId = setTimeout(findInputElement, 100);
return () => {
if (cleanup) cleanup();
clearTimeout(timeoutId);
};
}, [onUpdateMnemonic]);
const handlePaste = async () => {
try {
const clipboardText = await readText();
if (clipboardText) {
onUpdateMnemonic(clipboardText.trim());
const textarea = inputRef.current?.querySelector('textarea');
const input = textarea || inputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to paste from clipboard:', err);
}
};
return (
<Box position="relative" ref={inputRef}>
<OriginalMnemonicInput mnemonic={mnemonic} onUpdateMnemonic={onUpdateMnemonic} {...otherProps} />
<Box
sx={{
position: 'absolute',
right: '14px',
top: '16px',
zIndex: 1,
}}
>
<PasteButton onPaste={handlePaste} />
</Box>
</Box>
);
};
@@ -12,7 +12,6 @@ export const TauriLink: React.FC<LinkProps & any> = (props) => {
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
event.preventDefault();
console.log('Opening link in browser:', href);
await openUrl(href);
}
};
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Box, Button, FormControl, Stack } from '@mui/material';
import { AppContext } from 'src/context';
import { isPasswordCreated } from 'src/requests';
import { MnemonicInput } from '@nymproject/react/textfields/Mnemonic';
import { EnhancedMnemonicInput } from 'src/components/Login/MnemonicLoginFormWrapper';
import { Subtitle } from '../components';
export const SignInMnemonic = () => {
@@ -38,7 +38,11 @@ export const SignInMnemonic = () => {
}}
>
<Stack spacing={2}>
<MnemonicInput mnemonic={mnemonic} onUpdateMnemonic={(mnc) => setMnemonic(mnc)} error={error} />
<EnhancedMnemonicInput
mnemonic={mnemonic}
onUpdateMnemonic={(mnc: string) => setMnemonic(mnc)}
error={error}
/>
<Button variant="contained" size="large" fullWidth type="submit">
Sign in with mnemonic
</Button>
@@ -1,7 +1,7 @@
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, FormControl, Stack } from '@mui/material';
import { PasswordInput } from '@nymproject/react/textfields/Password';
import { EnhancedPasswordInput } from 'src/components/Login/LoginPasswordFormWrapper';
import { Subtitle } from '../components';
import { AppContext } from '../../../context/main';
@@ -21,10 +21,11 @@ export const SignInPassword = () => {
}}
>
<Stack spacing={2}>
<PasswordInput
{/* Use the password wrapper input instead */}
<EnhancedPasswordInput
label="Enter password"
password={password}
onUpdatePassword={(pswd) => setPassword(pswd)}
onUpdatePassword={(pswd: any) => setPassword(pswd)}
error={error}
autoFocus
/>
@@ -52,6 +52,7 @@ export const NodeTestPage = () => {
} catch (e) {
setError('Node test failed, please try again');
testStateRef.current = 'Stopped';
// eslint-disable-next-line no-console
console.log(e);
} finally {
setIsLoading(false);
@@ -67,6 +68,7 @@ export const NodeTestPage = () => {
await client.tester.init(validator, nodeTesterId);
setNodeTestClient(client);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
setError('Failed to load node tester client, please try again');
}