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) { export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('room', contact.public_key); useRememberedServerPassword('room', contact.public_key);
const cached = useMemo(() => getCachedRoom(contact.public_key), [contact.public_key]);
const [loginLoading, setLoginLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(cached?.loginError ?? null);
const [authenticated, setAuthenticated] = useState(false); const [authenticated, setAuthenticated] = useState(cached?.authenticated ?? false);
const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(null); const [lastLoginAttempt, setLastLoginAttempt] = useState<ServerLoginAttemptState | null>(
cached?.lastLoginAttempt ?? null
);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [paneData, setPaneData] = useState<RoomPaneData>({ const [paneData, setPaneData] = useState<RoomPaneData>(cached?.paneData ?? createInitialPaneData);
status: null, const [paneStates, setPaneStates] = useState<RoomPaneStates>(
acl: null, cached?.paneStates ?? createInitialPaneStates
lppTelemetry: null, );
}); const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates); cached?.consoleHistory ?? []
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]); );
const [consoleLoading, setConsoleLoading] = useState(false); const [consoleLoading, setConsoleLoading] = useState(false);
// Persist to cache on every state change
useEffect(() => { useEffect(() => {
setLoginLoading(false); setCachedRoom(contact.public_key, {
setLoginError(null); authenticated,
setAuthenticated(false); loginError,
setLastLoginAttempt(null); lastLoginAttempt,
setAdvancedOpen(false); paneData,
setPaneData({ paneStates,
status: null, consoleHistory,
acl: null,
lppTelemetry: null,
}); });
setPaneStates(createInitialPaneStates()); }, [
setConsoleHistory([]); contact.public_key,
setConsoleLoading(false); authenticated,
}, [contact.public_key]); loginError,
lastLoginAttempt,
paneData,
paneStates,
consoleHistory,
]);
useEffect(() => { useEffect(() => {
onAuthenticatedChange?.(authenticated); onAuthenticatedChange?.(authenticated);
@@ -1545,6 +1545,9 @@ function MqttCommunityConfigEditor({
<option value="none">None</option> <option value="none">None</option>
<option value="password">Username / Password</option> <option value="password">Username / Password</option>
</select> </select>
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
</div> </div>
</div> </div>
@@ -1566,10 +1569,6 @@ function MqttCommunityConfigEditor({
</div> </div>
)} )}
<p className="text-[0.8125rem] text-muted-foreground">
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
</p>
{authMode === 'token' && ( {authMode === 'token' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
+2 -1
View File
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; 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'; import type { Contact } from '../types';
vi.mock('../api', () => ({ vi.mock('../api', () => ({
@@ -50,6 +50,7 @@ describe('RoomServerPanel', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
resetRoomCacheForTests();
}); });
it('keeps room controls available when login is not confirmed', async () => { it('keeps room controls available when login is not confirmed', async () => {