;
description?: string;
@@ -18,6 +23,10 @@ export function RepeaterLogin({
repeaterName,
loading,
error,
+ password,
+ onPasswordChange,
+ rememberPassword,
+ onRememberPasswordChange,
onLogin,
onLoginAsGuest,
description = 'Log in to access repeater dashboard',
@@ -25,8 +34,6 @@ export function RepeaterLogin({
loginLabel = 'Login with Password',
guestLabel = 'Login as Guest / ACLs',
}: RepeaterLoginProps) {
- const [password, setPassword] = useState('');
-
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
@@ -53,13 +60,34 @@ export function RepeaterLogin({
data-1p-ignore="true"
data-bwignore="true"
value={password}
- onChange={(e) => setPassword(e.target.value)}
+ onChange={(e) => onPasswordChange(e.target.value)}
placeholder={passwordPlaceholder}
aria-label="Repeater password"
disabled={loading}
autoFocus
/>
+
+ onRememberPasswordChange(checked === true)}
+ />
+ Remember password
+
+
+ {rememberPassword && (
+
+ Passwords are stored unencrypted in local browser storage for this domain. It is
+ highly recommended to login via ACLs after your first successful login; saving the
+ password is not recommended.
+
+ )}
+
{error && (
{error}
diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx
index 80a07ff..bb1bbe9 100644
--- a/frontend/src/components/RoomServerPanel.tsx
+++ b/frontend/src/components/RoomServerPanel.tsx
@@ -15,6 +15,7 @@ import { AclPane } from './repeater/RepeaterAclPane';
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { RepeaterLogin } from './RepeaterLogin';
+import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
interface RoomServerPanelProps {
contact: Contact;
@@ -54,6 +55,8 @@ function createInitialPaneStates(): RoomPaneStates {
}
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
+ const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
+ useRememberedServerPassword('room', contact.public_key);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState(null);
const [loginMessage, setLoginMessage] = useState(null);
@@ -162,13 +165,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
const handleLogin = useCallback(
async (password: string) => {
await performLogin(password);
+ persistAfterLogin(password);
},
- [performLogin]
+ [performLogin, persistAfterLogin]
);
const handleLoginAsGuest = useCallback(async () => {
await performLogin('');
- }, [performLogin]);
+ persistAfterLogin('');
+ }, [performLogin, persistAfterLogin]);
const handleConsoleCommand = useCallback(
async (command: string) => {
@@ -211,17 +216,35 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
if (!authenticated) {
return (
-
-
+
+
+
+ Room server access is experimental and in public alpha. Please report any issues on{' '}
+
+ GitHub
+
+ .
+
+
+
);
}
diff --git a/frontend/src/hooks/useRememberedServerPassword.ts b/frontend/src/hooks/useRememberedServerPassword.ts
new file mode 100644
index 0000000..7c963b9
--- /dev/null
+++ b/frontend/src/hooks/useRememberedServerPassword.ts
@@ -0,0 +1,79 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+type ServerLoginKind = 'repeater' | 'room';
+
+const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
+
+type StoredPassword = {
+ password: string;
+};
+
+function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
+ return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
+}
+
+function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null {
+ try {
+ const raw = localStorage.getItem(getStorageKey(kind, publicKey));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Partial
;
+ if (typeof parsed.password !== 'string' || parsed.password.length === 0) {
+ return null;
+ }
+ return { password: parsed.password };
+ } catch {
+ return null;
+ }
+}
+
+export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) {
+ const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]);
+ const [password, setPassword] = useState('');
+ const [rememberPassword, setRememberPassword] = useState(false);
+
+ useEffect(() => {
+ const stored = loadStoredPassword(kind, publicKey);
+ if (!stored) {
+ setPassword('');
+ setRememberPassword(false);
+ return;
+ }
+ setPassword(stored.password);
+ setRememberPassword(true);
+ }, [kind, publicKey]);
+
+ const persistAfterLogin = useCallback(
+ (submittedPassword: string) => {
+ if (!rememberPassword) {
+ try {
+ localStorage.removeItem(storageKey);
+ } catch {
+ // localStorage may be unavailable
+ }
+ setPassword('');
+ return;
+ }
+
+ const trimmedPassword = submittedPassword.trim();
+ if (!trimmedPassword) {
+ return;
+ }
+
+ try {
+ localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
+ } catch {
+ // localStorage may be unavailable
+ }
+ setPassword(trimmedPassword);
+ },
+ [rememberPassword, storageKey]
+ );
+
+ return {
+ password,
+ setPassword,
+ rememberPassword,
+ setRememberPassword,
+ persistAfterLogin,
+ };
+}
diff --git a/frontend/src/test/repeaterLogin.test.tsx b/frontend/src/test/repeaterLogin.test.tsx
index 7401ef0..4d81fcf 100644
--- a/frontend/src/test/repeaterLogin.test.tsx
+++ b/frontend/src/test/repeaterLogin.test.tsx
@@ -7,6 +7,10 @@ describe('RepeaterLogin', () => {
repeaterName: 'TestRepeater',
loading: false,
error: null as string | null,
+ password: '',
+ onPasswordChange: vi.fn(),
+ rememberPassword: false,
+ onRememberPasswordChange: vi.fn(),
onLogin: vi.fn(),
onLoginAsGuest: vi.fn(),
};
@@ -26,20 +30,45 @@ describe('RepeaterLogin', () => {
render( );
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
+ expect(screen.getByText('Remember password')).toBeInTheDocument();
expect(screen.getByText('Login with Password')).toBeInTheDocument();
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
});
it('calls onLogin with trimmed password on submit', () => {
- render( );
-
- const input = screen.getByPlaceholderText('Repeater password...');
- fireEvent.change(input, { target: { value: ' secret ' } });
+ render( );
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
});
+ it('propagates password changes', () => {
+ render( );
+
+ const input = screen.getByPlaceholderText('Repeater password...');
+ fireEvent.change(input, { target: { value: 'new secret' } });
+
+ expect(defaultProps.onPasswordChange).toHaveBeenCalledWith('new secret');
+ });
+
+ it('toggles remember password checkbox', () => {
+ render( );
+
+ fireEvent.click(screen.getByLabelText('Remember password'));
+
+ expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true);
+ });
+
+ it('shows storage warning when remember password is enabled', () => {
+ render( );
+
+ expect(
+ screen.getByText(
+ /Passwords are stored unencrypted in local browser storage for this domain\./
+ )
+ ).toBeInTheDocument();
+ });
+
it('calls onLoginAsGuest when guest button clicked', () => {
render( );
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 7b0828b..720f237 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -1 +1,9 @@
import '@testing-library/jest-dom';
+
+class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+globalThis.ResizeObserver = ResizeObserver;
diff --git a/frontend/src/test/useRememberedServerPassword.test.ts b/frontend/src/test/useRememberedServerPassword.test.ts
new file mode 100644
index 0000000..65b57fa
--- /dev/null
+++ b/frontend/src/test/useRememberedServerPassword.test.ts
@@ -0,0 +1,77 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
+
+describe('useRememberedServerPassword', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('loads remembered passwords from localStorage', () => {
+ localStorage.setItem(
+ 'remoteterm-server-password:repeater:abc123',
+ 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(() => {
+ result.current.setRememberPassword(true);
+ });
+
+ act(() => {
+ result.current.persistAfterLogin(' hello ');
+ });
+
+ 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 } = 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');
+ });
+});