mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user