Files
TREK/client/src/components/Settings/useWandererConnection.ts
T
Maurice fbba4fd73b fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
2026-10-20 15:35:32 +02:00

161 lines
5.3 KiB
TypeScript

import {
WANDERER_TOKEN_MASK,
WANDERER_TOKEN_PREFIX,
type WandererConnection,
type WandererTestResponse,
} from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../i18n';
import { wandererErrorCode, wandererRepo } from '../../repo/wandererRepo';
import { useToast } from '../shared/Toast';
import { wandererErrorText } from '../Tours/wanderer/wandererErrors';
/** What the last test found, said in the card; null before a test. */
export interface WandererProbe {
ok: boolean;
message: string;
}
type Translate = (key: string, params?: Record<string, unknown>) => string;
/** What a test answered, as the line the card shows under its buttons. */
function probeOf(result: WandererTestResponse, t: Translate): WandererProbe {
if (!result.ok) return { ok: false, message: wandererErrorText(t, result.code ?? 'unknown') };
const message =
result.trailCount != null ? t('wanderer.test.okCount', { count: result.trailCount }) : t('wanderer.test.ok');
return { ok: true, message };
}
/**
* The wanderer connection card's logic: the form, saving, testing and
* disconnecting, with every failure turned into a sentence the reader can act
* on. The token field is never filled from the server; blank keeps the stored one.
*/
export function useWandererConnection() {
const { t } = useTranslation();
const toast = useToast();
const [url, setUrl] = useState('');
const [token, setToken] = useState('');
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
const [saved, setSaved] = useState<WandererConnection | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [probe, setProbe] = useState<WandererProbe | null>(null);
const errorText = useCallback((code: string) => wandererErrorText(t, code), [t]);
const apply = useCallback((connection: WandererConnection) => {
setSaved(connection);
setUrl(connection.url);
setAllowInsecureTls(connection.allowInsecureTls);
setToken('');
}, []);
useEffect(() => {
let cancelled = false;
wandererRepo
.getSettings()
.then((connection) => {
if (!cancelled) apply(connection);
})
.catch(() => {
if (!cancelled) toast.error(t('wanderer.loadFailed'));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [apply, t, toast]);
const typedToken = token.trim();
/** A typed token that wanderer would read as no token at all, said before it is sent. */
const tokenInvalid =
typedToken !== '' && typedToken !== WANDERER_TOKEN_MASK && !typedToken.startsWith(WANDERER_TOKEN_PREFIX);
const connected = !!saved?.connected;
const canSave = !!url.trim() && !tokenInvalid && (connected || typedToken !== '');
const save = useCallback(async () => {
if (!canSave) return;
setSaving(true);
try {
const result = await wandererRepo.saveSettings({
url: url.trim(),
token: typedToken || undefined,
allowInsecureTls,
});
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.saved'));
if (result.warning === 'private_ip') toast.info(t('wanderer.privateIp'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [allowInsecureTls, apply, canSave, errorText, t, toast, typedToken, url]);
const test = useCallback(async () => {
if (!url.trim() || tokenInvalid) return;
setTesting(true);
try {
const result = await wandererRepo.test({ url: url.trim(), token: typedToken || undefined, allowInsecureTls });
setProbe(probeOf(result, t));
if (!typedToken) setSaved(await wandererRepo.getSettings());
} catch (error: unknown) {
setProbe({ ok: false, message: errorText(wandererErrorCode(error)) });
} finally {
setTesting(false);
}
}, [allowInsecureTls, errorText, t, tokenInvalid, typedToken, url]);
const disconnect = useCallback(async () => {
setSaving(true);
try {
await wandererRepo.disconnect();
apply(await wandererRepo.getSettings());
setProbe(null);
toast.success(t('wanderer.disconnected'));
} catch (error: unknown) {
toast.error(errorText(wandererErrorCode(error)));
} finally {
setSaving(false);
}
}, [apply, errorText, t, toast]);
const lastCheck =
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
? errorText(saved.lastCheckError)
: null;
/**
* Changes whenever the server's view of the connection does: connecting,
* disconnecting, a saved token (which clears the last check) or a test that
* stored a new result. The watch row re-reads itself on it, since each of
* those can lift or set its "token refused" state.
*/
const checkKey = `${connected}|${saved?.lastCheckedAt ?? ''}|${saved?.lastCheckOk ?? ''}`;
return {
url,
setUrl,
token,
setToken,
allowInsecureTls,
toggleInsecureTls: () => setAllowInsecureTls((value) => !value),
connected,
loading,
saving,
testing,
probe,
tokenInvalid,
canSave,
lastCheck,
checkKey,
save,
test,
disconnect,
};
}