feat(config): add central maintenance mode

Adds a platform-wide maintenance switch controlled from the admin panel.
A single kernel.request subscriber (priority 6, after the firewall listener)
short-circuits every request with 503, so no controller has to check it and
all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are
covered at once.

- SiteConfig gains five maintenance_* keys; no entity change, no migration
- MaintenanceService caches the state in Redis for 30s and is fail-open:
  a Redis or database failure never takes the site down by itself
- API responses reuse the BaseController::error() envelope with code
  MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained
  Twig page (inline CSS, noindex) that renders even mid-deploy
- Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings
  reachable, otherwise an admin could neither sign in nor switch it back off
- Admin bypass falls back to decoding the Authorization JWT, because several
  admin-panel endpoints sit in the public_endpoints firewall (security: false)
  where no token is ever resolved and isGranted always returns false
- A kernel.exception handler at priority 20 covers routing 404/405 and
  firewall 401, which are thrown before the request listener runs
- app:maintenance on|off|status is the escape hatch when the panel is down

Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around
`=` are rejected by Symfony Dotenv, which made every console command and the
whole app fatal. The secret already lives in .env.local, as the comment above
that line instructs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-19 22:01:34 +03:30
co-authored by Claude Fable 5
parent 6275b3da1e
commit 7ac8ddbd25
11 changed files with 1005 additions and 2 deletions
+89 -1
View File
@@ -10,7 +10,9 @@ import { numericField } from '../lib/forms';
import {
Cog6ToothIcon, ClockIcon, CalculatorIcon, CreditCardIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, CheckCircleIcon, ExclamationTriangleIcon, EyeIcon, EyeSlashIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline';
import ConfirmDialog from '../components/ui/ConfirmDialog';
interface TaxHistoryRow {
tax_percent: number;
@@ -46,6 +48,12 @@ const schema = z.object({
mellat_password: z.string(),
sep_enabled: z.string(),
sep_terminal_id: z.string(),
// maintenance mode
maintenance_enabled: z.string(),
maintenance_title: z.string().min(1, 'عنوان صفحه تعمیرات الزامی است'),
maintenance_message: z.string().min(1, 'پیام صفحه تعمیرات الزامی است'),
maintenance_retry_after: z.string(),
maintenance_allowed_ips: z.string(),
});
type FormValues = z.infer<typeof schema>;
@@ -78,11 +86,16 @@ const toForm = (s: Partial<Settings>): FormValues => ({
mellat_password: s.mellat_password ?? '',
sep_enabled: s.sep_enabled ?? '1',
sep_terminal_id: s.sep_terminal_id ?? '',
maintenance_enabled: s.maintenance_enabled ?? '0',
maintenance_title: s.maintenance_title ?? 'در حال به‌روزرسانی سیستم',
maintenance_message: s.maintenance_message ?? '',
maintenance_retry_after: s.maintenance_retry_after ?? '600',
maintenance_allowed_ips: s.maintenance_allowed_ips ?? '',
});
// ── Section definitions (drive the nav rail + search) ───────────────────────
type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms';
type SectionId = 'general' | 'appointments' | 'financial' | 'payment' | 'sms' | 'maintenance';
interface SectionDef {
id: SectionId;
@@ -99,6 +112,7 @@ const SECTIONS: SectionDef[] = [
{ id: 'financial', label: 'مالی', desc: 'پورسانت، مالیات و کارمزدها', Icon: CalculatorIcon, bg: 'var(--info-bg)', fg: 'var(--info)', keywords: 'پورسانت مالیات کارمزد پیامک نوبت مبلغ ریال tax commission' },
{ id: 'payment', label: 'درگاه پرداخت', desc: 'ملت، سپ و حالت تست', Icon: CreditCardIcon, bg: 'var(--success-bg)', fg: 'var(--success)', keywords: 'درگاه پرداخت ملت سپ mellat sep terminal تست gateway' },
{ id: 'sms', label: 'پیامک', desc: 'پیکربندی سرویس پیامک', Icon: ChatBubbleLeftRightIcon, bg: 'var(--violet-bg)', fg: 'var(--violet)', keywords: 'پیامک sms کاوه‌نگار kavenegar api' },
{ id: 'maintenance', label: 'حالت تعمیرات', desc: 'قطع سراسری سرویس', Icon: WrenchScrewdriverIcon, bg: 'var(--danger-bg)', fg: 'var(--danger)', keywords: 'تعمیرات نگهداری maintenance قطع سرویس بستن سایت downtime' },
];
// ── Small presentational helpers ────────────────────────────────────────────
@@ -150,6 +164,7 @@ export default function SettingsPage() {
const [active, setActive] = useState<SectionId>('general');
const [search, setSearch] = useState('');
const [savedFlash, setSavedFlash] = useState(false);
const [confirmMaintenance, setConfirmMaintenance] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['admin-settings'],
@@ -222,10 +237,21 @@ export default function SettingsPage() {
const upgradeCommissionEnabled = watch('upgrade_commission_enabled') === '1';
const taxEnabled = watch('tax_enabled') === '1';
const altchaEnabled = watch('altcha_enabled') === '1';
const maintenanceEnabled = watch('maintenance_enabled') === '1';
const toggle = (name: keyof FormValues, current: boolean) =>
setValue(name, current ? '0' : '1', { shouldDirty: true });
// روشن کردن این کلید کل سایت را برای کاربران عادی از دسترس خارج می‌کند؛
// خاموش کردن بی‌خطر است و تأیید نمی‌خواهد.
const onMaintenanceToggle = () => {
if (maintenanceEnabled) {
toggle('maintenance_enabled', true);
return;
}
setConfirmMaintenance(true);
};
// search filters the nav rail
const q = search.trim();
const filtered = useMemo(
@@ -245,6 +271,19 @@ export default function SettingsPage() {
return (
<div className="fade-in">
{maintenanceEnabled && (
<div className="toggle-row warn on" style={{ marginBottom: 'var(--gap)', cursor: 'pointer' }}
onClick={() => setActive('maintenance')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<ExclamationTriangleIcon style={{ width: 20, height: 20, color: 'var(--danger)' }} />
<div>
<div className="tr-title">سایت در حالت تعمیرات است</div>
<div className="tr-desc">کاربران عادی به هیچ بخشی دسترسی ندارند. برای بازگشایی به بخش «حالت تعمیرات» بروید.</div>
</div>
</div>
</div>
)}
<div style={{ marginBottom: 'var(--gap)' }}>
<h1 className="section-title">تنظیمات</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>پیکربندی کلی پلتفرم تغییرات پس از ذخیره اعمال میشوند</div>
@@ -497,6 +536,45 @@ export default function SettingsPage() {
</Field>
</div>
)}
{/* حالت تعمیرات */}
{current.id === 'maintenance' && (
<>
<div className={`toggle-row warn${maintenanceEnabled ? ' on' : ''}`}>
<div>
<div className="tr-title">{maintenanceEnabled ? 'حالت تعمیرات فعال است' : 'حالت تعمیرات غیرفعال'}</div>
<div className="tr-desc">
{maintenanceEnabled
? 'سایت عمومی، اپ دسکتاپ و همه APIها برای کاربران عادی با کد ۵۰۳ بسته‌اند. فقط مدیران دسترسی دارند.'
: 'با فعال‌سازی، همه درخواست‌ها به‌جز ورود و همین صفحه تنظیمات مسدود می‌شوند. فقط مدیران دسترسی خواهند داشت.'}
</div>
</div>
<Toggle checked={maintenanceEnabled} onChange={onMaintenanceToggle} label="حالت تعمیرات" />
</div>
<div className="settings-grid" style={{ marginTop: 18 }}>
<Field label="عنوان صفحه تعمیرات" required error={errors.maintenance_title?.message}
hint="در سربرگ صفحه‌ای که به کاربران نمایش داده می‌شود.">
<input {...register('maintenance_title')} className={`input${errors.maintenance_title ? ' err' : ''}`} placeholder="در حال به‌روزرسانی سیستم" />
</Field>
<Field label="مدت تخمینی قطعی" hint="در هدر Retry-After پاسخ‌ها ارسال می‌شود تا کلاینت‌ها بدانند چه زمانی دوباره تلاش کنند.">
<div className="input-suffix">
<input {...numericField(register('maintenance_retry_after'))} className="input" style={{ maxWidth: 140 }} placeholder="600" />
<span className="suf">ثانیه</span>
</div>
</Field>
<Field label="پیام صفحه تعمیرات" required span2 error={errors.maintenance_message?.message}
hint="همین متن هم در صفحه HTML و هم در پاسخ JSON APIها به کاربران نمایش داده می‌شود.">
<textarea {...register('maintenance_message')} className={`input${errors.maintenance_message ? ' err' : ''}`} rows={3}
placeholder="سامانه موقتاً برای انجام عملیات فنی در دسترس نیست." />
</Field>
<Field label="IPهای مجاز" optional span2
hint="با کاما جدا کنید. این IPها حتی در حالت تعمیرات و بدون ورود، دسترسی کامل دارند — برای تست پیش از بازگشایی.">
<input {...register('maintenance_allowed_ips')} className="input" dir="ltr" placeholder="1.2.3.4, 5.6.7.8" />
</Field>
</div>
</>
)}
</div>
</div>
@@ -528,6 +606,16 @@ export default function SettingsPage() {
</div>
)}
</form>
<ConfirmDialog
open={confirmMaintenance}
danger
title="فعال‌سازی حالت تعمیرات"
message="با ذخیره این تغییر، سایت عمومی، اپ دسکتاپ و تمام APIها برای همه کاربران غیرمدیر بسته می‌شوند. فقط مدیران می‌توانند وارد شوند."
confirmLabel="بله، فعال کن"
onConfirm={() => { toggle('maintenance_enabled', false); setConfirmMaintenance(false); }}
onCancel={() => setConfirmMaintenance(false)}
/>
</div>
);
}