Persist login status for room servers. Closes #244.

This commit is contained in:
Jack Kingsman
2026-05-13 16:52:32 -07:00
parent 79c8b45d89
commit b77660196b
3 changed files with 97 additions and 28 deletions
+92 -23
View File
@@ -61,38 +61,107 @@ function createInitialPaneStates(): RoomPaneStates {
};
}
function createInitialPaneData(): RoomPaneData {
return { status: null, acl: null, lppTelemetry: null };
}
// ---------------------------------------------------------------------------
// In-memory LRU cache so room login state survives conversation switches
// ---------------------------------------------------------------------------
interface RoomCacheEntry {
authenticated: boolean;
loginError: string | null;
lastLoginAttempt: ServerLoginAttemptState | null;
paneData: RoomPaneData;
paneStates: RoomPaneStates;
consoleHistory: ConsoleEntry[];
}
const MAX_CACHED_ROOMS = 8;
const roomCache = new Map<string, RoomCacheEntry>();
function getCachedRoom(publicKey: string): RoomCacheEntry | null {
const cached = roomCache.get(publicKey);
if (!cached) return null;
// Touch for LRU
roomCache.delete(publicKey);
roomCache.set(publicKey, cached);
return {
...cached,
paneData: { ...cached.paneData },
paneStates: {
status: { ...cached.paneStates.status, loading: false },
acl: { ...cached.paneStates.acl, loading: false },
lppTelemetry: { ...cached.paneStates.lppTelemetry, loading: false },
},
consoleHistory: cached.consoleHistory.map((e) => ({ ...e })),
};
}
function setCachedRoom(publicKey: string, entry: RoomCacheEntry) {
roomCache.delete(publicKey);
roomCache.set(publicKey, {
...entry,
paneData: { ...entry.paneData },
paneStates: {
status: { ...entry.paneStates.status, loading: false },
acl: { ...entry.paneStates.acl, loading: false },
lppTelemetry: { ...entry.paneStates.lppTelemetry, loading: false },
},
consoleHistory: entry.consoleHistory.map((e) => ({ ...e })),
});
if (roomCache.size > MAX_CACHED_ROOMS) {
const lruKey = roomCache.keys().next().value as string | undefined;
if (lruKey) roomCache.delete(lruKey);
}
}
export function resetRoomCacheForTests() {
roomCache.clear();
}
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('room', contact.public_key);
const cached = useMemo(() => getCachedRoom(contact.public_key), [contact.public_key]);
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 [loginError, setLoginError] = useState<string | null>(cached?.loginError ?? null);
const [authenticated, setAuthenticated] = useState(cached?.authenticated ?? false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cached?.lastLoginAttempt ?? null
);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({
status: null,
acl: null,
lppTelemetry: null,
});
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [paneData, setPaneData] = useState<RoomPaneData>(cached?.paneData ?? createInitialPaneData);
const [paneStates, setPaneStates] = useState<RoomPaneStates>(
cached?.paneStates ?? createInitialPaneStates
);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
cached?.consoleHistory ?? []
);
const [consoleLoading, setConsoleLoading] = useState(false);
// Persist to cache on every state change
useEffect(() => {
setLoginLoading(false);
setLoginError(null);
setAuthenticated(false);
setLastLoginAttempt(null);
setAdvancedOpen(false);
setPaneData({
status: null,
acl: null,
lppTelemetry: null,
setCachedRoom(contact.public_key, {
authenticated,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
});
setPaneStates(createInitialPaneStates());
setConsoleHistory([]);
setConsoleLoading(false);
}, [contact.public_key]);
}, [
contact.public_key,
authenticated,
loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
]);
useEffect(() => {
onAuthenticatedChange?.(authenticated);
@@ -1545,6 +1545,9 @@ function MqttCommunityConfigEditor({
<option value="none">None</option>
<option value="password">Username / Password</option>
</select>
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
</div>
</div>
@@ -1566,10 +1569,6 @@ function MqttCommunityConfigEditor({
</div>
)}
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
{authMode === 'token' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
+2 -1
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { RoomServerPanel } from '../components/RoomServerPanel';
import { RoomServerPanel, resetRoomCacheForTests } from '../components/RoomServerPanel';
import type { Contact } from '../types';
vi.mock('../api', () => ({
@@ -50,6 +50,7 @@ describe('RoomServerPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
resetRoomCacheForTests();
});
it('keeps room controls available when login is not confirmed', async () => {