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:
return (
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 (
f"No login confirmation was heard from the {label}. "
"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 { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -69,6 +70,7 @@ export function RepeaterDashboard({
loggedIn,
loginLoading,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
@@ -249,6 +251,14 @@ export function RepeaterDashboard({
/>
) : (
<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 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
<div className="flex flex-col gap-4">

View File

@@ -16,7 +16,13 @@ import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import {
buildServerLoginAttemptFromError,
buildServerLoginAttemptFromResponse,
type ServerLoginAttemptState,
} from '../utils/serverLoginState';
interface RoomServerPanelProps {
contact: Contact;
@@ -61,6 +67,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [authenticated, setAuthenticated] = useState(false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
@@ -75,6 +82,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
setLoginLoading(false);
setLoginError(null);
setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false);
setPaneData({
status: null,
@@ -129,26 +137,32 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const performLogin = useCallback(
async (password: string) => {
async (nextPassword: string, method: 'password' | 'blank') => {
if (loginLoading) return;
setLoginLoading(true);
setLoginError(null);
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);
if (result.authenticated) {
toast.success('Room login confirmed');
toast.success('Login confirmed by the room server.');
} else {
toast.warning('Room login not confirmed', {
description: result.message ?? 'Room login was not confirmed',
toast.warning("Couldn't confirm room login", {
description:
result.message ??
'No confirmation came back from the room server. You can still open tools and try again.',
});
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setLastLoginAttempt(buildServerLoginAttemptFromError(method, message, 'room server'));
setAuthenticated(true);
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 {
setLoginLoading(false);
}
@@ -157,15 +171,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
);
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
persistAfterLogin(password);
async (nextPassword: string) => {
await performLogin(nextPassword, 'password');
persistAfterLogin(nextPassword);
},
[performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
await performLogin('', 'blank');
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 showLoginFailureState =
lastLoginAttempt !== null && lastLoginAttempt.outcome !== 'confirmed';
if (!authenticated) {
return (
@@ -236,7 +252,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
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"
guestLabel="Login with Existing Access / Guest"
/>
</div>
</div>
@@ -245,7 +261,43 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
<section className="border-b border-border bg-muted/20 px-4 py-3">
<div className="flex justify-end">
<div className="space-y-3">
{showLoginFailureState ? (
<ServerLoginStatusBanner
attempt={lastLoginAttempt}
loading={loginLoading}
canRetryPassword={password.trim().length > 0}
onRetryPassword={() => handleLogin(password)}
onRetryBlank={handleLoginAsGuest}
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"
@@ -255,6 +307,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
</Button>
</div>
</div>
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
<SheetHeader className="sr-only">
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
<p className="text-sm text-muted-foreground">{panelTitle}</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleLoginAsGuest}
disabled={loginLoading}
className="self-start sm:self-auto"
>
Refresh ACL Login
</Button>
</div>
</div>
<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';
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
type StoredPassword = {
password: string;
};
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
const inMemoryPasswords = new Map<string, StoredPassword>();
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
}
@@ -33,37 +34,46 @@ export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: st
useEffect(() => {
const stored = loadStoredPassword(kind, publicKey);
if (!stored) {
setPassword('');
if (stored) {
setPassword(stored.password);
setRememberPassword(true);
return;
}
const inMemoryStored = inMemoryPasswords.get(storageKey);
if (inMemoryStored) {
setPassword(inMemoryStored.password);
setRememberPassword(false);
return;
}
setPassword(stored.password);
setRememberPassword(true);
}, [kind, publicKey]);
setPassword('');
setRememberPassword(false);
}, [kind, publicKey, storageKey]);
const persistAfterLogin = useCallback(
(submittedPassword: string) => {
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
inMemoryPasswords.set(storageKey, { password: trimmedPassword });
if (!rememberPassword) {
try {
localStorage.removeItem(storageKey);
} catch {
// localStorage may be unavailable
}
setPassword('');
return;
}
const trimmedPassword = submittedPassword.trim();
if (!trimmedPassword) {
return;
}
} else {
try {
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
} catch {
// localStorage may be unavailable
}
}
setPassword(trimmedPassword);
},
[rememberPassword, storageKey]

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
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');
});
@@ -92,6 +94,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
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', {
description: 'Auth failed',
});
@@ -125,6 +129,8 @@ describe('useRepeaterDashboard', () => {
expect(result.current.loggedIn).toBe(true);
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', {
description:
'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)
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
@pytest.mark.asyncio