fix(cabinet): recover "Сервис недоступен" false-positive + top-up fixes

- health probe: tolerant timeout (12s) + retry before flagging the backend down. A
  hardcoded 5s probe racing auth bootstrap falsely showed ServiceUnavailableScreen on
  slow devices / cold mobile connections while the 30s API requests would have
  succeeded ("works on one device, not another on the same Wi-Fi"). The recovery poll
  self-reschedules with the tolerant timeout so slow devices auto-recover.
- TopUpAmount: fetch payment methods with a real query (fixes the infinite spinner on a
  cold cache / browser-back) and use canonical RUB for quick-amount chips so FX rounding
  can't reject a min-amount selection in non-RUB locales.
- settings UI: render secret values as a masked password input (pairs with the backend
  secret masking); leaving the field empty keeps the stored secret.
- deps: npm audit fix (18 -> 5 advisories).

Also bundles in-progress settings env-lock UI work.
This commit is contained in:
c0mrade
2026-06-10 16:15:55 +03:00
parent 75a570b862
commit 16fad9f4fe
14 changed files with 391 additions and 227 deletions

View File

@@ -13,7 +13,9 @@ interface QuickTogglesProps {
export function QuickToggles({ settings, onUpdate, disabled, className }: QuickTogglesProps) {
const { t } = useTranslation();
const booleanSettings = settings.filter((s) => s.type === 'bool' && !s.read_only);
const booleanSettings = settings.filter(
(s) => s.type === 'bool' && !s.read_only && !s.env_locked,
);
if (booleanSettings.length === 0) {
return null;

View File

@@ -40,7 +40,10 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
const inputRef = useRef<HTMLInputElement>(null);
const currentValue = String(setting.current ?? '');
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
// Secrets are always edited via the single-line (password) input — never a textarea — and
// never pre-filled with the masked value, so leaving the field empty means "keep current".
const needsTextarea =
!setting.is_secret && (isLongValue(currentValue) || isListOrJsonKey(setting.key));
// Auto-resize textarea
useEffect(() => {
@@ -51,11 +54,19 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
}, [value, isEditing]);
const handleStart = () => {
setValue(currentValue);
// For secrets, start from an empty field (the displayed value is just the mask) so the
// admin types a brand-new value; leaving it empty is treated as "no change".
setValue(setting.is_secret ? '' : currentValue);
setIsEditing(true);
};
const handleSave = () => {
// Empty secret field = the admin opened edit but didn't change anything → keep the stored
// secret instead of overwriting it with an empty value.
if (setting.is_secret && value === '') {
handleCancel();
return;
}
onUpdate(value);
setIsEditing(false);
};
@@ -128,7 +139,14 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
<div className="flex items-center gap-2">
<input
ref={inputRef}
type={setting.type === 'int' || setting.type === 'float' ? 'number' : 'text'}
type={
setting.is_secret
? 'password'
: setting.type === 'int' || setting.type === 'float'
? 'number'
: 'text'
}
autoComplete={setting.is_secret ? 'new-password' : undefined}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {

View File

@@ -30,6 +30,10 @@ export function SettingRow({
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
// env-locked keys behave like read-only here: the .env value shadows the DB,
// so editing would be silently discarded by the bot. Show the value, no input.
const locked = setting.read_only || setting.env_locked;
// Check if this is a long/complex value
const isLongValue = (() => {
const val = String(setting.current ?? '');
@@ -65,6 +69,15 @@ export function SettingRow({
{t('admin.settings.readOnly')}
</span>
)}
{setting.env_locked && !setting.read_only && (
<span
className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400"
title={t('admin.settings.envLockedHint')}
>
<LockIcon />
{t('admin.settings.envLocked')}
</span>
)}
</div>
{description && (
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
@@ -100,10 +113,17 @@ export function SettingRow({
<div
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
>
{setting.read_only ? (
// Read-only display
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
{locked ? (
// Read-only / env-locked display
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
</div>
{setting.env_locked && !setting.read_only && (
<p className="text-xs leading-relaxed text-dark-500">
{t('admin.settings.envLockedHint')}
</p>
)}
</div>
) : setting.type === 'bool' ? (
// Boolean toggle
@@ -158,7 +178,7 @@ export function SettingRow({
</div>
{/* Reset button for long values - shown below */}
{isLongValue && setting.has_override && !setting.read_only && setting.type !== 'bool' && (
{isLongValue && setting.has_override && !locked && setting.type !== 'bool' && (
<div className="mt-3 flex justify-end">
<button
onClick={onReset}

View File

@@ -37,6 +37,8 @@ export function SettingsTableRow({
const isModified = setting.has_override;
const isBool = setting.type === 'bool';
const boolChecked = setting.current === true || setting.current === 'true';
// env-locked keys are pinned in .env and shadow the DB — show value, no input.
const locked = setting.read_only || setting.env_locked;
const isLongValue = (() => {
const val = String(setting.current ?? '');
@@ -84,14 +86,17 @@ export function SettingsTableRow({
</span>
)}
{setting.has_override && !setting.read_only && (
{setting.has_override && !locked && (
<span className="rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-sky-400">
{t('admin.settings.badgeDb')}
</span>
)}
{setting.read_only && (
<span className="flex items-center gap-0.5 rounded-full bg-warning-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400">
{setting.env_locked && (
<span
className="flex items-center gap-0.5 rounded-full bg-warning-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400"
title={t('admin.settings.envLockedHint')}
>
{t('admin.settings.badgeEnv')}
<LockIcon className="h-3 w-3" />
</span>
@@ -116,7 +121,7 @@ export function SettingsTableRow({
isLongValue ? 'w-full' : 'max-lg:self-end lg:flex-shrink-0',
)}
>
{setting.read_only ? (
{locked ? (
<span className="max-w-[240px] truncate rounded bg-dark-700/30 px-3 py-1.5 font-mono text-xs text-dark-400">
{isBool
? boolChecked
@@ -138,7 +143,7 @@ export function SettingsTableRow({
)}
{/* Reset button -- hover-reveal when has_override */}
{isModified && !setting.read_only && (
{isModified && !locked && (
<button
onClick={onReset}
disabled={isResetting}

View File

@@ -4,13 +4,12 @@ import { useQueryClient } from '@tanstack/react-query';
import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { pingBackend, hasEverReachedBackend } from '../../api/health';
import { HEALTH } from '../../config/constants';
import { CloudWarningIcon, RestartIcon } from '@/components/icons';
import { Button } from '@/components/primitives';
import { cn } from '@/lib/utils';
import BlockingShell from './BlockingShell';
const POLL_INTERVAL_MS = 5000;
/**
* Full-screen state shown when the backend is unreachable (transport-level
* failure), replacing the blank loader the app used to get stuck on. Polls the
@@ -54,20 +53,24 @@ export default function ServiceUnavailableScreen() {
}
}, [recover]);
// Auto-recovery: probe immediately on mount, then every POLL_INTERVAL_MS.
// Auto-recovery: probe on mount, then re-schedule after each result resolves. Self-chaining
// (rather than a fixed setInterval) prevents the now-longer tolerant probe from overlapping
// with itself.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async () => {
if (cancelled) return;
if (await pingBackend()) {
if (!cancelled) recover();
return;
}
if (!cancelled) timer = setTimeout(tick, HEALTH.RECOVERY_POLL_INTERVAL_MS);
};
void tick();
const id = setInterval(tick, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
if (timer) clearTimeout(timer);
};
}, [recover]);