diff --git a/app/routers/server_control.py b/app/routers/server_control.py index 7919775..b3f9e9e 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -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." ) diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index e7d77db..988e3e1 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 { 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({ /> ) : (
+ 0} + onRetryPassword={() => handleRepeaterLogin(password)} + onRetryBlank={handleRepeaterGuestLogin} + blankRetryLabel="Retry Existing-Access Login" + /> {/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx index 875ee52..b5a0e40 100644 --- a/frontend/src/components/RoomServerPanel.tsx +++ b/frontend/src/components/RoomServerPanel.tsx @@ -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(null); const [authenticated, setAuthenticated] = useState(false); + const [lastLoginAttempt, setLastLoginAttempt] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [paneData, setPaneData] = useState({ 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" />
@@ -245,15 +261,52 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa return (
-
- +
+ {showLoginFailureState ? ( + 0} + onRetryPassword={() => handleLogin(password)} + onRetryBlank={handleLoginAsGuest} + blankRetryLabel="Retry Existing-Access Login" + showRetryActions={false} + /> + ) : null} +
+ {showLoginFailureState ? ( +
+ + +
+ ) : ( +
+ )} + +
@@ -269,15 +322,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa

Room Server Tools

{panelTitle}

-
diff --git a/frontend/src/components/ServerLoginStatusBanner.tsx b/frontend/src/components/ServerLoginStatusBanner.tsx new file mode 100644 index 0000000..460fd61 --- /dev/null +++ b/frontend/src/components/ServerLoginStatusBanner.tsx @@ -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; + onRetryBlank: () => Promise | 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 ( +
+
+
+

+ {attempt?.summary ?? 'No server login attempt has been recorded in this view yet.'} +

+ {attempt?.details &&

{attempt.details}

} +
+ {shouldShowActions ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/hooks/useRememberedServerPassword.ts b/frontend/src/hooks/useRememberedServerPassword.ts index 7c963b9..c344b0e 100644 --- a/frontend/src/hooks/useRememberedServerPassword.ts +++ b/frontend/src/hooks/useRememberedServerPassword.ts @@ -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(); + 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; + } else { + 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); }, [rememberPassword, storageKey] diff --git a/frontend/src/hooks/useRepeaterDashboard.ts b/frontend/src/hooks/useRepeaterDashboard.ts index 0e02f9d..7609be7 100644 --- a/frontend/src/hooks/useRepeaterDashboard.ts +++ b/frontend/src/hooks/useRepeaterDashboard.ts @@ -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; 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; 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(cachedState?.loginError ?? null); + const [lastLoginAttempt, setLastLoginAttempt] = useState( + cachedState?.lastLoginAttempt ?? null + ); const [paneData, setPaneData] = useState( 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, diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 1d3e21d..19fe953 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -11,6 +11,7 @@ const mockHook: { loggedIn: false, loginLoading: false, loginError: null, + lastLoginAttempt: null, paneData: { status: null, nodeInfo: null, diff --git a/frontend/src/test/roomServerPanel.test.tsx b/frontend/src/test/roomServerPanel.test.tsx index 882e0e9..18b9373 100644 --- a/frontend/src/test/roomServerPanel.test.tsx +++ b/frontend/src/test/roomServerPanel.test.tsx @@ -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(); - 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(); + + 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(); + + 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.'); + }); }); diff --git a/frontend/src/test/useRememberedServerPassword.test.ts b/frontend/src/test/useRememberedServerPassword.test.ts index 65b57fa..743c334 100644 --- a/frontend/src/test/useRememberedServerPassword.test.ts +++ b/frontend/src/test/useRememberedServerPassword.test.ts @@ -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); }); }); diff --git a/frontend/src/test/useRepeaterDashboard.test.ts b/frontend/src/test/useRepeaterDashboard.test.ts index 0583892..2164893 100644 --- a/frontend/src/test/useRepeaterDashboard.test.ts +++ b/frontend/src/test/useRepeaterDashboard.test.ts @@ -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.', diff --git a/frontend/src/utils/serverLoginState.ts b/frontend/src/utils/serverLoginState.ts new file mode 100644 index 0000000..b95ce70 --- /dev/null +++ b/frontend/src/utils/serverLoginState.ts @@ -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(), + }; +} diff --git a/tests/test_radio_sync.py b/tests/test_radio_sync.py index e9c5a1f..6b92809 100644 --- a/tests/test_radio_sync.py +++ b/tests/test_radio_sync.py @@ -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