mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-03 16:32:00 +02:00
Add 'Show Key' prompt on private rooms to prevent key leakage. Closes #36
This commit is contained in:
@@ -24,11 +24,13 @@ export function ChannelInfoPane({
|
||||
}: ChannelInfoPaneProps) {
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
// Get live channel data from channels array (real-time via WS)
|
||||
const liveChannel = channelKey ? (channels.find((c) => c.key === channelKey) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
if (!channelKey) {
|
||||
setDetail(null);
|
||||
return;
|
||||
@@ -74,23 +76,33 @@ export function ChannelInfoPane({
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{channel.name.startsWith('#') || channel.name === 'Public'
|
||||
? channel.name
|
||||
: `#${channel.name}`}
|
||||
{channel.is_hashtag && !channel.name.startsWith('#')
|
||||
? `#${channel.name}`
|
||||
: channel.name}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(channel.key);
|
||||
toast.success('Channel key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{channel.key.toLowerCase()}
|
||||
</span>
|
||||
{!channel.is_hashtag && !showKey ? (
|
||||
<button
|
||||
className="text-xs font-mono text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => setShowKey(true)}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(channel.key);
|
||||
toast.success('Channel key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{channel.key.toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -32,12 +33,21 @@ export function ChatHeader({
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
}: ChatHeaderProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const isPrivateChannel =
|
||||
conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag;
|
||||
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
return (
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
@@ -82,22 +92,35 @@ export function ChatHeader({
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</h2>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||
</span>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChannelInfoPane } from '../components/ChannelInfoPane';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getChannelDetail: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
}
|
||||
|
||||
function makeDetail(channel: Channel): ChannelDetail {
|
||||
return {
|
||||
channel,
|
||||
message_counts: { last_1h: 0, last_24h: 0, last_48h: 0, last_7d: 0, all_time: 0 },
|
||||
first_message_at: null,
|
||||
unique_sender_count: 0,
|
||||
top_senders_24h: [],
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const baseProps = {
|
||||
onClose: noop,
|
||||
favorites: [] as Favorite[],
|
||||
onToggleFavorite: noop,
|
||||
};
|
||||
|
||||
describe('ChannelInfoPane key visibility', () => {
|
||||
it('shows key directly for hashtag channels', async () => {
|
||||
const key = 'AA'.repeat(16);
|
||||
const channel = makeChannel(key, '#general', true);
|
||||
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
|
||||
|
||||
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides key behind "Show Key" button for private channels', async () => {
|
||||
const key = 'BB'.repeat(16);
|
||||
const channel = makeChannel(key, 'Secret', false);
|
||||
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
|
||||
|
||||
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Secret')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals key when "Show Key" is clicked', async () => {
|
||||
const key = 'CC'.repeat(16);
|
||||
const channel = makeChannel(key, 'Private', false);
|
||||
mockGetChannelDetail.mockResolvedValue(makeDetail(channel));
|
||||
|
||||
render(<ChannelInfoPane {...baseProps} channelKey={key} channels={[channel]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Show Key'));
|
||||
|
||||
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets key visibility when channel changes', async () => {
|
||||
const key1 = 'DD'.repeat(16);
|
||||
const key2 = 'EE'.repeat(16);
|
||||
const ch1 = makeChannel(key1, 'Room1', false);
|
||||
const ch2 = makeChannel(key2, 'Room2', false);
|
||||
mockGetChannelDetail.mockImplementation((key) =>
|
||||
Promise.resolve(key === key1 ? makeDetail(ch1) : makeDetail(ch2))
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<ChannelInfoPane {...baseProps} channelKey={key1} channels={[ch1, ch2]} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reveal key for first channel
|
||||
fireEvent.click(screen.getByText('Show Key'));
|
||||
expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument();
|
||||
|
||||
// Switch channel — key should be hidden again
|
||||
rerender(<ChannelInfoPane {...baseProps} channelKey={key2} channels={[ch1, ch2]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Room2')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Conversation, Favorite } from '../types';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const baseProps = {
|
||||
contacts: [],
|
||||
config: null,
|
||||
favorites: [] as Favorite[],
|
||||
onTrace: noop,
|
||||
onToggleFavorite: noop,
|
||||
onDeleteChannel: noop,
|
||||
onDeleteContact: noop,
|
||||
};
|
||||
|
||||
describe('ChatHeader key visibility', () => {
|
||||
it('shows key directly for hashtag channels', () => {
|
||||
const key = 'AA'.repeat(16);
|
||||
const channel = makeChannel(key, '#general', true);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: '#general' };
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides key behind "Show Key" button for private channels', () => {
|
||||
const key = 'BB'.repeat(16);
|
||||
const channel = makeChannel(key, 'Secret Room', false);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: 'Secret Room' };
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
expect(screen.queryByText(key.toLowerCase())).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals key when "Show Key" is clicked', () => {
|
||||
const key = 'CC'.repeat(16);
|
||||
const channel = makeChannel(key, 'Private', false);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: 'Private' };
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Show Key'));
|
||||
|
||||
expect(screen.getByText(key.toLowerCase())).toBeInTheDocument();
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets key visibility when conversation changes', () => {
|
||||
const key1 = 'DD'.repeat(16);
|
||||
const key2 = 'EE'.repeat(16);
|
||||
const ch1 = makeChannel(key1, 'Room1', false);
|
||||
const ch2 = makeChannel(key2, 'Room2', false);
|
||||
const conv1: Conversation = { type: 'channel', id: key1, name: 'Room1' };
|
||||
const conv2: Conversation = { type: 'channel', id: key2, name: 'Room2' };
|
||||
|
||||
const { rerender } = render(
|
||||
<ChatHeader {...baseProps} conversation={conv1} channels={[ch1, ch2]} />
|
||||
);
|
||||
|
||||
// Reveal key for first conversation
|
||||
fireEvent.click(screen.getByText('Show Key'));
|
||||
expect(screen.getByText(key1.toLowerCase())).toBeInTheDocument();
|
||||
|
||||
// Switch conversation — key should be hidden again
|
||||
rerender(<ChatHeader {...baseProps} conversation={conv2} channels={[ch1, ch2]} />);
|
||||
|
||||
expect(screen.queryByText(key2.toLowerCase())).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Show Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows key directly for contacts', () => {
|
||||
const pubKey = '11'.repeat(32);
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[]} />);
|
||||
|
||||
expect(screen.getByText(pubKey)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies key to clipboard when revealed key is clicked', async () => {
|
||||
const key = 'FF'.repeat(16);
|
||||
const channel = makeChannel(key, 'Priv', false);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: 'Priv' };
|
||||
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
// Reveal key then click to copy
|
||||
fireEvent.click(screen.getByText('Show Key'));
|
||||
fireEvent.click(screen.getByText(key.toLowerCase()));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(key);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user