Add password-remember + warning on save

This commit is contained in:
Jack Kingsman
2026-03-19 20:09:35 -07:00
parent 5b166c4b66
commit d05312c157
7 changed files with 282 additions and 23 deletions

View File

@@ -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}
/>
) : (
<div className="space-y-4">

View File

@@ -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<void>;
onLoginAsGuest: () => Promise<void>;
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
/>
<label
htmlFor="remember-server-password"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Checkbox
id="remember-server-password"
checked={rememberPassword}
disabled={loading}
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
/>
<span>Remember password</span>
</label>
{rememberPassword && (
<p className="text-xs text-muted-foreground">
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.
</p>
)}
{error && (
<p className="text-sm text-destructive text-center" role="alert">
{error}

View File

@@ -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<string | null>(null);
const [loginMessage, setLoginMessage] = useState<string | null>(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 (
<div className="flex-1 overflow-y-auto">
<RepeaterLogin
repeaterName={panelTitle}
loading={loginLoading}
error={loginError}
onLogin={handleLogin}
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
/>
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
Room server access is experimental and in public alpha. Please report any issues on{' '}
<a
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-2 hover:text-warning/80"
>
GitHub
</a>
.
</div>
<RepeaterLogin
repeaterName={panelTitle}
loading={loginLoading}
error={loginError}
password={password}
onPasswordChange={setPassword}
rememberPassword={rememberPassword}
onRememberPasswordChange={setRememberPassword}
onLogin={handleLogin}
onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest"
/>
</div>
</div>
);
}

View File

@@ -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<StoredPassword>;
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,
};
}

View File

@@ -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(<RepeaterLogin {...defaultProps} />);
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(<RepeaterLogin {...defaultProps} />);
const input = screen.getByPlaceholderText('Repeater password...');
fireEvent.change(input, { target: { value: ' secret ' } });
render(<RepeaterLogin {...defaultProps} password=" secret " />);
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
it('propagates password changes', () => {
render(<RepeaterLogin {...defaultProps} />);
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(<RepeaterLogin {...defaultProps} />);
fireEvent.click(screen.getByLabelText('Remember password'));
expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true);
});
it('shows storage warning when remember password is enabled', () => {
render(<RepeaterLogin {...defaultProps} rememberPassword={true} />);
expect(
screen.getByText(
/Passwords are stored unencrypted in local browser storage for this domain\./
)
).toBeInTheDocument();
});
it('calls onLoginAsGuest when guest button clicked', () => {
render(<RepeaterLogin {...defaultProps} />);

View File

@@ -1 +1,9 @@
import '@testing-library/jest-dom';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;

View File

@@ -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');
});
});