diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index c020778..c14f5db 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -5,6 +5,7 @@ import { Button } from './ui/button'; import { Bell, Route, Star, Trash2 } from 'lucide-react'; import { DirectTraceIcon } from './DirectTraceIcon'; import { RepeaterLogin } from './RepeaterLogin'; +import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; @@ -81,8 +82,18 @@ export function RepeaterDashboard({ rebootRepeater, syncClock, } = useRepeaterDashboard(conversation, { hasAdvertLocation }); + const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = + useRememberedServerPassword('repeater', conversation.id); const isFav = isFavorite(favorites, 'contact', conversation.id); + const handleRepeaterLogin = async (nextPassword: string) => { + await login(nextPassword); + persistAfterLogin(nextPassword); + }; + const handleRepeaterGuestLogin = async () => { + await loginAsGuest(); + persistAfterLogin(''); + }; // Loading all panes indicator const anyLoading = Object.values(paneStates).some((s) => s.loading); @@ -221,8 +232,12 @@ export function RepeaterDashboard({ repeaterName={conversation.name} loading={loginLoading} error={loginError} - onLogin={login} - onLoginAsGuest={loginAsGuest} + password={password} + onPasswordChange={setPassword} + rememberPassword={rememberPassword} + onRememberPasswordChange={setRememberPassword} + onLogin={handleRepeaterLogin} + onLoginAsGuest={handleRepeaterGuestLogin} /> ) : (
diff --git a/frontend/src/components/RepeaterLogin.tsx b/frontend/src/components/RepeaterLogin.tsx index 9c24b56..bd85462 100644 --- a/frontend/src/components/RepeaterLogin.tsx +++ b/frontend/src/components/RepeaterLogin.tsx @@ -1,11 +1,16 @@ -import { useState, useCallback, type FormEvent } from 'react'; +import { useCallback, type FormEvent } from 'react'; import { Input } from './ui/input'; import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; interface RepeaterLoginProps { repeaterName: string; loading: boolean; error: string | null; + password: string; + onPasswordChange: (password: string) => void; + rememberPassword: boolean; + onRememberPasswordChange: (checked: boolean) => void; onLogin: (password: string) => Promise; onLoginAsGuest: () => Promise; description?: string; @@ -18,6 +23,10 @@ export function RepeaterLogin({ repeaterName, loading, error, + password, + onPasswordChange, + rememberPassword, + onRememberPasswordChange, onLogin, onLoginAsGuest, description = 'Log in to access repeater dashboard', @@ -25,8 +34,6 @@ export function RepeaterLogin({ loginLabel = 'Login with Password', guestLabel = 'Login as Guest / ACLs', }: RepeaterLoginProps) { - const [password, setPassword] = useState(''); - const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault(); @@ -53,13 +60,34 @@ export function RepeaterLogin({ data-1p-ignore="true" data-bwignore="true" value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => onPasswordChange(e.target.value)} placeholder={passwordPlaceholder} aria-label="Repeater password" disabled={loading} autoFocus /> + + + {rememberPassword && ( +

+ Passwords are stored unencrypted in local browser storage for this domain. It is + highly recommended to login via ACLs after your first successful login; saving the + password is not recommended. +

+ )} + {error && (

{error} diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx index 80a07ff..bb1bbe9 100644 --- a/frontend/src/components/RoomServerPanel.tsx +++ b/frontend/src/components/RoomServerPanel.tsx @@ -15,6 +15,7 @@ import { AclPane } from './repeater/RepeaterAclPane'; import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane'; import { ConsolePane } from './repeater/RepeaterConsolePane'; import { RepeaterLogin } from './RepeaterLogin'; +import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; interface RoomServerPanelProps { contact: Contact; @@ -54,6 +55,8 @@ function createInitialPaneStates(): RoomPaneStates { } export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) { + const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = + useRememberedServerPassword('room', contact.public_key); const [loginLoading, setLoginLoading] = useState(false); const [loginError, setLoginError] = useState(null); const [loginMessage, setLoginMessage] = useState(null); @@ -162,13 +165,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa const handleLogin = useCallback( async (password: string) => { await performLogin(password); + persistAfterLogin(password); }, - [performLogin] + [performLogin, persistAfterLogin] ); const handleLoginAsGuest = useCallback(async () => { await performLogin(''); - }, [performLogin]); + persistAfterLogin(''); + }, [performLogin, persistAfterLogin]); const handleConsoleCommand = useCallback( async (command: string) => { @@ -211,17 +216,35 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa if (!authenticated) { return ( -

- +
+
+
+ Room server access is experimental and in public alpha. Please report any issues on{' '} + + GitHub + + . +
+ +
); } diff --git a/frontend/src/hooks/useRememberedServerPassword.ts b/frontend/src/hooks/useRememberedServerPassword.ts new file mode 100644 index 0000000..7c963b9 --- /dev/null +++ b/frontend/src/hooks/useRememberedServerPassword.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +type ServerLoginKind = 'repeater' | 'room'; + +const STORAGE_KEY_PREFIX = 'remoteterm-server-password'; + +type StoredPassword = { + password: string; +}; + +function getStorageKey(kind: ServerLoginKind, publicKey: string): string { + return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`; +} + +function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null { + try { + const raw = localStorage.getItem(getStorageKey(kind, publicKey)); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.password !== 'string' || parsed.password.length === 0) { + return null; + } + return { password: parsed.password }; + } catch { + return null; + } +} + +export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) { + const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]); + const [password, setPassword] = useState(''); + const [rememberPassword, setRememberPassword] = useState(false); + + useEffect(() => { + const stored = loadStoredPassword(kind, publicKey); + if (!stored) { + setPassword(''); + setRememberPassword(false); + return; + } + setPassword(stored.password); + setRememberPassword(true); + }, [kind, publicKey]); + + const persistAfterLogin = useCallback( + (submittedPassword: string) => { + if (!rememberPassword) { + try { + localStorage.removeItem(storageKey); + } catch { + // localStorage may be unavailable + } + setPassword(''); + return; + } + + const trimmedPassword = submittedPassword.trim(); + if (!trimmedPassword) { + return; + } + + try { + localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword })); + } catch { + // localStorage may be unavailable + } + setPassword(trimmedPassword); + }, + [rememberPassword, storageKey] + ); + + return { + password, + setPassword, + rememberPassword, + setRememberPassword, + persistAfterLogin, + }; +} diff --git a/frontend/src/test/repeaterLogin.test.tsx b/frontend/src/test/repeaterLogin.test.tsx index 7401ef0..4d81fcf 100644 --- a/frontend/src/test/repeaterLogin.test.tsx +++ b/frontend/src/test/repeaterLogin.test.tsx @@ -7,6 +7,10 @@ describe('RepeaterLogin', () => { repeaterName: 'TestRepeater', loading: false, error: null as string | null, + password: '', + onPasswordChange: vi.fn(), + rememberPassword: false, + onRememberPasswordChange: vi.fn(), onLogin: vi.fn(), onLoginAsGuest: vi.fn(), }; @@ -26,20 +30,45 @@ describe('RepeaterLogin', () => { render(); expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument(); + expect(screen.getByText('Remember password')).toBeInTheDocument(); expect(screen.getByText('Login with Password')).toBeInTheDocument(); expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument(); }); it('calls onLogin with trimmed password on submit', () => { - render(); - - const input = screen.getByPlaceholderText('Repeater password...'); - fireEvent.change(input, { target: { value: ' secret ' } }); + render(); fireEvent.submit(screen.getByText('Login with Password').closest('form')!); expect(defaultProps.onLogin).toHaveBeenCalledWith('secret'); }); + it('propagates password changes', () => { + render(); + + const input = screen.getByPlaceholderText('Repeater password...'); + fireEvent.change(input, { target: { value: 'new secret' } }); + + expect(defaultProps.onPasswordChange).toHaveBeenCalledWith('new secret'); + }); + + it('toggles remember password checkbox', () => { + render(); + + fireEvent.click(screen.getByLabelText('Remember password')); + + expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true); + }); + + it('shows storage warning when remember password is enabled', () => { + render(); + + expect( + screen.getByText( + /Passwords are stored unencrypted in local browser storage for this domain\./ + ) + ).toBeInTheDocument(); + }); + it('calls onLoginAsGuest when guest button clicked', () => { render(); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 7b0828b..720f237 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1 +1,9 @@ import '@testing-library/jest-dom'; + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver = ResizeObserver; diff --git a/frontend/src/test/useRememberedServerPassword.test.ts b/frontend/src/test/useRememberedServerPassword.test.ts new file mode 100644 index 0000000..65b57fa --- /dev/null +++ b/frontend/src/test/useRememberedServerPassword.test.ts @@ -0,0 +1,77 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; + +describe('useRememberedServerPassword', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('loads remembered passwords from localStorage', () => { + localStorage.setItem( + 'remoteterm-server-password:repeater:abc123', + JSON.stringify({ password: 'stored-secret' }) + ); + + const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123')); + + expect(result.current.password).toBe('stored-secret'); + expect(result.current.rememberPassword).toBe(true); + }); + + it('stores passwords after login when remember is enabled', () => { + const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key')); + + act(() => { + result.current.setRememberPassword(true); + }); + + act(() => { + result.current.persistAfterLogin(' hello '); + }); + + expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe( + JSON.stringify({ password: 'hello' }) + ); + expect(result.current.password).toBe('hello'); + }); + + it('clears stored passwords when login is done with remember disabled', () => { + localStorage.setItem( + 'remoteterm-server-password:repeater:abc123', + JSON.stringify({ password: 'stored-secret' }) + ); + + const { result } = renderHook(() => useRememberedServerPassword('repeater', 'abc123')); + + act(() => { + result.current.setRememberPassword(false); + }); + + act(() => { + result.current.persistAfterLogin('new-secret'); + }); + + expect(localStorage.getItem('remoteterm-server-password:repeater:abc123')).toBeNull(); + expect(result.current.password).toBe(''); + }); + + it('preserves remembered passwords on guest login when remember stays enabled', () => { + localStorage.setItem( + 'remoteterm-server-password:room:room-key', + JSON.stringify({ password: 'stored-secret' }) + ); + + const { result } = renderHook(() => useRememberedServerPassword('room', 'room-key')); + + act(() => { + result.current.persistAfterLogin(''); + }); + + expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe( + JSON.stringify({ password: 'stored-secret' }) + ); + expect(result.current.password).toBe('stored-secret'); + }); +});