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:
@@ -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">
|
||||
|
||||
@@ -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,15 +261,52 @@ 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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
<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"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{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">
|
||||
@@ -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">
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
} 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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockHook: {
|
||||
loggedIn: false,
|
||||
loginLoading: false,
|
||||
loginError: null,
|
||||
lastLoginAttempt: null,
|
||||
paneData: {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user