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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user