mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-02 03:23:00 +02:00
Add password-remember + warning on save
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal file
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
77
frontend/src/test/useRememberedServerPassword.test.ts
Normal file
77
frontend/src/test/useRememberedServerPassword.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user