mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Be much, much clearer about room server ops. Closes #78.
This commit is contained in:
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,7 +261,43 @@ 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">
|
||||||
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -255,6 +307,7 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
|||||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||||
</Button>
|
</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">
|
||||||
<SheetHeader className="sr-only">
|
<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>
|
<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">
|
||||||
|
|||||||
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal file
76
frontend/src/components/ServerLoginStatusBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedPassword = submittedPassword.trim();
|
|
||||||
if (!trimmedPassword) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage may be unavailable
|
// localStorage may be unavailable
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setPassword(trimmedPassword);
|
setPassword(trimmedPassword);
|
||||||
},
|
},
|
||||||
[rememberPassword, storageKey]
|
[rememberPassword, storageKey]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
107
frontend/src/utils/serverLoginState.ts
Normal file
107
frontend/src/utils/serverLoginState.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user