Be much, much clearer about room server ops. Closes #78.

This commit is contained in:
Jack Kingsman
2026-03-27 12:58:00 -07:00
parent 6e5256acce
commit 7151cf3846
12 changed files with 410 additions and 113 deletions

View File

@@ -62,7 +62,7 @@ def _login_rejected_message(label: str) -> str:
def _login_send_failed_message(label: str) -> str: def _login_send_failed_message(label: str) -> str:
return ( return (
f"The login request could not be sent to the {label}. " f"The login request could not be sent to the {label}. "
f"The control panel is still available, but authenticated actions may fail until a login succeeds." f"You're free to attempt interaction; try logging in again if authenticated actions fail."
) )
@@ -70,7 +70,7 @@ def _login_timeout_message(label: str) -> str:
return ( return (
f"No login confirmation was heard from the {label}. " f"No login confirmation was heard from the {label}. "
"That can mean the password was wrong or the reply was missed in transit. " "That can mean the password was wrong or the reply was missed in transit. "
"The control panel is still available; try logging in again if authenticated actions fail." "You're free to attempt interaction; try logging in again if authenticated actions fail."
) )

View File

@@ -5,6 +5,7 @@ import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react'; import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon'; import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin'; import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites'; import { isFavorite } from '../utils/favorites';
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
loggedIn, loggedIn,
loginLoading, loginLoading,
loginError, loginError,
lastLoginAttempt,
paneData, paneData,
paneStates, paneStates,
consoleHistory, consoleHistory,
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
/> />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleRepeaterLogin(password)}
onRetryBlank={handleRepeaterGuestLogin}
blankRetryLabel="Retry Existing-Access Login"
/>
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */} {/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View File

@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane'; import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane'; import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin'; import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
interface RoomServerPanelProps { interface RoomServerPanelProps {
contact: Contact; contact: Contact;
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({ const [paneData, setPaneData] = useState<RoomPaneData>({
status: null, status: null,
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
setLoginLoading(false); setLoginLoading(false);
setLoginError(null); setLoginError(null);
setAuthenticated(false); setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false); setAdvancedOpen(false);
setPaneData({ setPaneData({
status: null, status: null,
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
); );
const performLogin = useCallback( const performLogin = useCallback(
async (password: string) => { async (nextPassword: string, method: 'password' | 'blank') => {
if (loginLoading) return; if (loginLoading) return;
setLoginLoading(true); setLoginLoading(true);
setLoginError(null); setLoginError(null);
try { try {
const result = await api.roomLogin(contact.public_key, password); const result = await api.roomLogin(contact.public_key, nextPassword);
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'room server'));
setAuthenticated(true); setAuthenticated(true);
if (result.authenticated) { if (result.authenticated) {
toast.success('Room login confirmed'); toast.success('Login confirmed by the room server.');
} else { } else {
toast.warning('Room login not confirmed', { toast.warning("Couldn't confirm room login", {
description: result.message ?? 'Room login was not confirmed', description:
result.message ??
'No confirmation came back from the room server. You can still open tools and try again.',
}); });
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'; const message = err instanceof Error ? err.message : 'Unknown error';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
setAuthenticated(true); setAuthenticated(true);
setLoginError(message); setLoginError(message);
toast.error('Room login failed', { description: message }); toast.error('Room login request failed', {
description: `${message}. You can still open tools and retry the login from here.`,
});
} finally { } finally {
setLoginLoading(false); setLoginLoading(false);
} }
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
); );
const handleLogin = useCallback( const handleLogin = useCallback(
async (password: string) => { async (nextPassword: string) => {
await performLogin(password); await performLogin(nextPassword, 'password');
persistAfterLogin(password); persistAfterLogin(nextPassword);
}, },
[performLogin, persistAfterLogin] [performLogin, persistAfterLogin]
); );
const handleLoginAsGuest = useCallback(async () => { const handleLoginAsGuest = useCallback(async () => {
await performLogin(''); await performLogin('', 'blank');
persistAfterLogin(''); persistAfterLogin('');
}, [performLogin, persistAfterLogin]); }, [performLogin, persistAfterLogin]);
@@ -207,6 +221,8 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
); );
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]); const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
const showLoginFailureState =
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
if (!authenticated) { if (!authenticated) {
return ( return (
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
onLoginAsGuest={handleLoginAsGuest} onLoginAsGuest={handleLoginAsGuest}
description="Log in with the room password or use ACL/guest access to enter this room server" description="Log in with the room password or use ACL/guest access to enter this room server"
passwordPlaceholder="Room server password..." passwordPlaceholder="Room server password..."
guestLabel="Login with ACL / Guest" guestLabel="Login with Existing Access / Guest"
/> />
</div> </div>
</div> </div>
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return ( return (
<section className="border-b border-border bg-muted/20 px-4 py-3"> <section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex justify-end"> <div className="space-y-3">
<Button {showLoginFailureState ? (
type="button" <ServerLoginStatusBanner
variant="outline" attempt={lastLoginAttempt}
size="sm" loading={loginLoading}
onClick={() => setAdvancedOpen((prev) => !prev)} canRetryPassword={password.trim().length > 0}
> onRetryPassword={() => handleLogin(password)}
{advancedOpen ? 'Hide Tools' : 'Show Tools'} onRetryBlank={handleLoginAsGuest}
</Button> blankRetryLabel="Retry Existing-Access Login"
showRetryActions={false}
/>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
{showLoginFailureState ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleLogin(password)}
disabled={loginLoading || password.trim().length === 0}
>
Retry Password Login
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLoginAsGuest}
disabled={loginLoading}
>
Retry Existing-Access Login
</Button>
</div>
) : (
<div />
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdvancedOpen((prev) => !prev)}
>
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div> </div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}> <Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col"> <SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
<h2 className="truncate text-base font-semibold">Room Server Tools</h2> <h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p> <p className="text-sm text-muted-foreground">{panelTitle}</p>
</div> </div>
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
className="self-start sm:self-auto"
>
Refresh ACL Login
</Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">

View File

@@ -0,0 +1,76 @@
import { Button } from './ui/button';
import type { ServerLoginAttemptState } from '../utils/serverLoginState';
import { getServerLoginAttemptTone } from '../utils/serverLoginState';
import { cn } from '../lib/utils';
interface ServerLoginStatusBannerProps {
attempt: ServerLoginAttemptState | null;
loading: boolean;
canRetryPassword: boolean;
onRetryPassword: () => Promise<void> | void;
onRetryBlank: () => Promise<void> | void;
passwordRetryLabel?: string;
blankRetryLabel?: string;
showRetryActions?: boolean;
}
export function ServerLoginStatusBanner({
attempt,
loading,
canRetryPassword,
onRetryPassword,
onRetryBlank,
passwordRetryLabel = 'Retry Password Login',
blankRetryLabel = 'Retry Existing-Access Login',
showRetryActions = true,
}: ServerLoginStatusBannerProps) {
if (attempt?.outcome === 'confirmed') {
return null;
}
const tone = getServerLoginAttemptTone(attempt);
const shouldShowActions = showRetryActions;
const toneClassName =
tone === 'success'
? 'border-success/30 bg-success/10 text-success'
: tone === 'warning'
? 'border-warning/30 bg-warning/10 text-warning'
: tone === 'destructive'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: 'border-border bg-muted/40 text-foreground';
return (
<div className={cn('rounded-md border px-4 py-3', toneClassName)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'}
</p>
{attempt?.details && <p className="text-xs opacity-90">{attempt.details}</p>}
</div>
{shouldShowActions ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryPassword()}
disabled={loading || !canRetryPassword}
>
{passwordRetryLabel}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onRetryBlank()}
disabled={loading}
>
{blankRetryLabel}
</Button>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -2,12 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
type ServerLoginKind = 'repeater' | 'room'; type ServerLoginKind = 'repeater' | 'room';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = { type StoredPassword = {
password: string; password: string;
}; };
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
const inMemoryPasswords = new Map<string, StoredPassword>();
function getStorageKey(kind: ServerLoginKind, publicKey: string): string { function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`; return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
} }
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
useEffect(() => { useEffect(() => {
const stored = loadStoredPassword(kind, publicKey); const stored = loadStoredPassword(kind, publicKey);
if (!stored) { if (stored) {
setPassword(''); setPassword(stored.password);
setRememberPassword(true);
return;
}
const inMemoryStored = inMemoryPasswords.get(storageKey);
if (inMemoryStored) {
setPassword(inMemoryStored.password);
setRememberPassword(false); setRememberPassword(false);
return; return;
} }
setPassword(stored.password);
setRememberPassword(true); setPassword('');
}, [kind, publicKey]); setRememberPassword(false);
}, [kind, publicKey, storageKey]);
const persistAfterLogin = useCallback( const persistAfterLogin = useCallback(
(submittedPassword: string) => { (submittedPassword: string) => {
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
if (!rememberPassword) { if (!rememberPassword) {
try { try {
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
} catch { } catch {
// localStorage may be unavailable // localStorage may be unavailable
} }
setPassword(''); } else {
return; try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
} }
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
setPassword(trimmedPassword); setPassword(trimmedPassword);
}, },
[rememberPassword, storageKey] [rememberPassword, storageKey]

View File

@@ -15,6 +15,11 @@ import type {
RepeaterLppTelemetryResponse, RepeaterLppTelemetryResponse,
CommandResponse, CommandResponse,
} from '../types'; } from '../types';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000; const RETRY_DELAY_MS = 2000;
@@ -41,6 +46,7 @@ interface PaneData {
interface RepeaterDashboardCacheEntry { interface RepeaterDashboardCacheEntry {
loggedIn: boolean; loggedIn: boolean;
loginError: string | null; loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData; paneData: PaneData;
paneStates: Record<PaneName, PaneState>; paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[]; consoleHistory: ConsoleEntry[];
@@ -119,6 +125,7 @@ function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry |
return { return {
loggedIn: cached.loggedIn, loggedIn: cached.loggedIn,
loginError: cached.loginError, loginError: cached.loginError,
lastLoginAttempt: cached.lastLoginAttempt,
paneData: clonePaneData(cached.paneData), paneData: clonePaneData(cached.paneData),
paneStates: normalizePaneStates(cached.paneStates), paneStates: normalizePaneStates(cached.paneStates),
consoleHistory: cloneConsoleHistory(cached.consoleHistory), consoleHistory: cloneConsoleHistory(cached.consoleHistory),
@@ -130,6 +137,7 @@ function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
repeaterDashboardCache.set(publicKey, { repeaterDashboardCache.set(publicKey, {
loggedIn: entry.loggedIn, loggedIn: entry.loggedIn,
loginError: entry.loginError, loginError: entry.loginError,
lastLoginAttempt: entry.lastLoginAttempt,
paneData: clonePaneData(entry.paneData), paneData: clonePaneData(entry.paneData),
paneStates: normalizePaneStates(entry.paneStates), paneStates: normalizePaneStates(entry.paneStates),
consoleHistory: cloneConsoleHistory(entry.consoleHistory), consoleHistory: cloneConsoleHistory(entry.consoleHistory),
@@ -173,6 +181,7 @@ export interface UseRepeaterDashboardResult {
loggedIn: boolean; loggedIn: boolean;
loginLoading: boolean; loginLoading: boolean;
loginError: string | null; loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: PaneData; paneData: PaneData;
paneStates: Record<PaneName, PaneState>; paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[]; consoleHistory: ConsoleEntry[];
@@ -203,6 +212,9 @@ export function useRepeaterDashboard(
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false); const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null); const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cachedState?.lastLoginAttempt ?? null
);
const [paneData, setPaneData] = useState<PaneData>( const [paneData, setPaneData] = useState<PaneData>(
cachedState?.paneData ?? createInitialPaneData cachedState?.paneData ?? createInitialPaneData
@@ -243,11 +255,20 @@ export function useRepeaterDashboard(
cacheState(conversationId, { cacheState(conversationId, {
loggedIn, loggedIn,
loginError, loginError,
lastLoginAttempt,
paneData, paneData,
paneStates, paneStates,
consoleHistory, consoleHistory,
}); });
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]); }, [
consoleHistory,
conversationId,
loggedIn,
loginError,
lastLoginAttempt,
paneData,
paneStates,
]);
useEffect(() => { useEffect(() => {
paneDataRef.current = paneData; paneDataRef.current = paneData;
@@ -267,12 +288,14 @@ export function useRepeaterDashboard(
const publicKey = getPublicKey(); const publicKey = getPublicKey();
if (!publicKey) return; if (!publicKey) return;
const conversationId = publicKey; const conversationId = publicKey;
const method = password.trim().length > 0 ? 'password' : 'blank';
setLoginLoading(true); setLoginLoading(true);
setLoginError(null); setLoginError(null);
try { try {
const result = await api.repeaterLogin(publicKey, password); const result = await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return; if (activeIdRef.current !== conversationId) return;
setLastLoginAttempt(buildServerLoginAttemptFromResponse(method, result, 'repeater'));
setLoggedIn(true); setLoggedIn(true);
if (!result.authenticated) { if (!result.authenticated) {
const msg = result.message ?? 'Repeater login was not confirmed'; const msg = result.message ?? 'Repeater login was not confirmed';
@@ -282,6 +305,7 @@ export function useRepeaterDashboard(
} catch (err) { } catch (err) {
if (activeIdRef.current !== conversationId) return; if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed'; const msg = err instanceof Error ? err.message : 'Login failed';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, msg, 'repeater'));
setLoggedIn(true); setLoggedIn(true);
setLoginError(msg); setLoginError(msg);
toast.error('Login request failed', { toast.error('Login request failed', {
@@ -475,6 +499,7 @@ export function useRepeaterDashboard(
loggedIn, loggedIn,
loginLoading, loginLoading,
loginError, loginError,
lastLoginAttempt,
paneData, paneData,
paneStates, paneStates,
consoleHistory, consoleHistory,

View File

@@ -11,6 +11,7 @@ const mockHook: {
loggedIn: false, loggedIn: false,
loginLoading: false, loginLoading: false,
loginError: null, loginError: null,
lastLoginAttempt: null,
paneData: { paneData: {
status: null, status: null,
nodeInfo: null, nodeInfo: null,

View File

@@ -56,22 +56,84 @@ describe('RoomServerPanel', () => {
status: 'timeout', status: 'timeout',
authenticated: false, authenticated: false,
message: message:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.', "No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
}); });
const onAuthenticatedChange = vi.fn(); const onAuthenticatedChange = vi.fn();
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />); render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
fireEvent.click(screen.getByText('Login with ACL / Guest')); fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument(); expect(screen.getByText('Show Tools')).toBeInTheDocument();
}); });
expect(screen.getByText('Show Tools')).toBeInTheDocument(); expect(screen.getByText('Show Tools')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', { expect(screen.getByText('Retry Existing-Access Login')).toBeInTheDocument();
expect(mockToast.warning).toHaveBeenCalledWith("Couldn't confirm room login", {
description: description:
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.', "No login confirmation was heard from the room server. You're free to try sending messages; try logging in again if authenticated actions fail.",
}); });
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true); expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
}); });
it('retains the last password for one-click retry after unlocking the panel', async () => {
mockApi.roomLogin
.mockResolvedValueOnce({
status: 'timeout',
authenticated: false,
message: 'No reply heard',
})
.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.change(screen.getByLabelText('Repeater password'), {
target: { value: 'secret-room-password' },
});
fireEvent.click(screen.getByText('Login with Password'));
await waitFor(() => {
expect(screen.getByText('Retry Password Login')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Retry Password Login'));
await waitFor(() => {
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
1,
roomContact.public_key,
'secret-room-password'
);
expect(mockApi.roomLogin).toHaveBeenNthCalledWith(
2,
roomContact.public_key,
'secret-room-password'
);
});
});
it('shows only a success toast after a confirmed login', async () => {
mockApi.roomLogin.mockResolvedValueOnce({
status: 'ok',
authenticated: true,
message: null,
});
render(<RoomServerPanel contact={roomContact} />);
fireEvent.click(screen.getByText('Login with Existing Access / Guest'));
await waitFor(() => {
expect(screen.getByText('Show Tools')).toBeInTheDocument();
});
expect(screen.queryByText('Login confirmed by the room server.')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Password Login')).not.toBeInTheDocument();
expect(screen.queryByText('Retry Existing-Access Login')).not.toBeInTheDocument();
expect(mockToast.success).toHaveBeenCalledWith('Login confirmed by the room server.');
});
}); });

View File

@@ -8,70 +8,24 @@ describe('useRememberedServerPassword', () => {
localStorage.clear(); localStorage.clear();
}); });
it('loads remembered passwords from localStorage', () => { it('restores the last in-memory password when local remember is disabled', () => {
localStorage.setItem( const { result, unmount } = renderHook(() =>
'remoteterm-server-password:repeater:abc123', useRememberedServerPassword('room', 'aa'.repeat(32))
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(() => { act(() => {
result.current.setRememberPassword(true); result.current.setPassword('room-secret');
result.current.persistAfterLogin('room-secret');
}); });
act(() => { expect(result.current.password).toBe('room-secret');
result.current.persistAfterLogin(' hello '); unmount();
});
expect(localStorage.getItem('remoteterm-server-password:room:room-key')).toBe( const { result: remounted } = renderHook(() =>
JSON.stringify({ password: 'hello' }) useRememberedServerPassword('room', 'aa'.repeat(32))
);
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')); expect(remounted.current.password).toBe('room-secret');
expect(remounted.current.rememberPassword).toBe(false);
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');
}); });
}); });

View File

@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true); expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe(null); expect(result.current.loginError).toBe(null);
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('confirmed');
expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret'); expect(mockApi.repeaterLogin).toHaveBeenCalledWith(REPEATER_KEY, 'secret');
}); });
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true); expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Auth failed'); expect(result.current.loginError).toBe('Auth failed');
expect(result.current.lastLoginAttempt?.heardBack).toBe(true);
expect(result.current.lastLoginAttempt?.outcome).toBe('not_confirmed');
expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', { expect(mockToast.error).toHaveBeenCalledWith('Login not confirmed', {
description: 'Auth failed', description: 'Auth failed',
}); });
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true); expect(result.current.loggedIn).toBe(true);
expect(result.current.loginError).toBe('Network error'); expect(result.current.loginError).toBe('Network error');
expect(result.current.lastLoginAttempt?.heardBack).toBe(false);
expect(result.current.lastLoginAttempt?.outcome).toBe('request_failed');
expect(mockToast.error).toHaveBeenCalledWith('Login request failed', { expect(mockToast.error).toHaveBeenCalledWith('Login request failed', {
description: description:
'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.', 'Network error. The dashboard is still available, but repeater operations may fail until a login succeeds.',

View File

@@ -0,0 +1,107 @@
import type { RepeaterLoginResponse } from '../types';
export type ServerLoginMethod = 'password' | 'blank';
export type ServerLoginAttemptState =
| {
method: ServerLoginMethod;
outcome: 'confirmed';
summary: string;
details: string | null;
heardBack: true;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'not_confirmed';
summary: string;
details: string | null;
heardBack: boolean;
at: number;
}
| {
method: ServerLoginMethod;
outcome: 'request_failed';
summary: string;
details: string | null;
heardBack: false;
at: number;
};
export function getServerLoginMethodLabel(
method: ServerLoginMethod,
blankLabel = 'existing-access'
): string {
return method === 'password' ? 'password' : blankLabel;
}
export function getServerLoginAttemptTone(
attempt: ServerLoginAttemptState | null
): 'success' | 'warning' | 'destructive' | 'muted' {
if (!attempt) return 'muted';
if (attempt.outcome === 'confirmed') return 'success';
if (attempt.outcome === 'not_confirmed') return 'warning';
return 'destructive';
}
export function buildServerLoginAttemptFromResponse(
method: ServerLoginMethod,
result: RepeaterLoginResponse,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const at = Date.now();
const target = `the ${entityLabel}`;
if (result.authenticated) {
return {
method,
outcome: 'confirmed',
summary: `Login confirmed by ${target}.`,
details: null,
heardBack: true,
at,
};
}
if (result.status === 'timeout') {
return {
method,
outcome: 'not_confirmed',
summary: `We couldn't confirm the login.`,
details:
result.message ??
`No confirmation came back from ${target} after the ${methodLabel} login attempt.`,
heardBack: false,
at,
};
}
return {
method,
outcome: 'not_confirmed',
summary: `Login was not confirmed.`,
details:
result.message ??
`${target} responded, but did not confirm the ${methodLabel} login attempt.`,
heardBack: true,
at,
};
}
export function buildServerLoginAttemptFromError(
method: ServerLoginMethod,
message: string,
entityLabel: string
): ServerLoginAttemptState {
const methodLabel = getServerLoginMethodLabel(method);
const target = `the ${entityLabel}`;
return {
method,
outcome: 'request_failed',
summary: `We couldn't send the login request.`,
details: `${target} never acknowledged the ${methodLabel} login attempt. ${message}`,
heardBack: false,
at: Date.now(),
};
}

View File

@@ -487,7 +487,9 @@ class TestSyncAndOffloadAll:
): ):
result = await sync_and_offload_all(mock_mc) result = await sync_and_offload_all(mock_mc)
mock_start.assert_called_once_with(initial_radio_contacts=radio_contacts, expected_mc=mock_mc) mock_start.assert_called_once_with(
initial_radio_contacts=radio_contacts, expected_mc=mock_mc
)
assert result["contact_reconcile_started"] is True assert result["contact_reconcile_started"] is True
@pytest.mark.asyncio @pytest.mark.asyncio