Auth: multibot TG MAX logging fix 500
This commit is contained in:
214
frontend/src/components/SupportForm.tsx
Normal file
214
frontend/src/components/SupportForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
|
||||
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, message as antMessage } from 'antd';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface SupportLimits {
|
||||
max_count: number;
|
||||
max_size_per_file: number;
|
||||
allowed_types: string;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export interface SupportFormProps {
|
||||
/** Привязка к обращению (из карточки жалобы) */
|
||||
claimId?: string;
|
||||
/** bar | complaint_card */
|
||||
source?: 'bar' | 'complaint_card';
|
||||
/** После успешной отправки */
|
||||
onSuccess?: () => void;
|
||||
/** Компактный вид (модалка) */
|
||||
compact?: boolean;
|
||||
/** Скрыть заголовок «По обращению №…» когда передан claimId */
|
||||
hideClaimLabel?: boolean;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SupportForm({
|
||||
claimId,
|
||||
source = 'bar',
|
||||
onSuccess,
|
||||
compact = false,
|
||||
hideClaimLabel = false,
|
||||
}: SupportFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [limits, setLimits] = useState<SupportLimits | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/support/limits')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: SupportLimits | null) => {
|
||||
if (data) setLimits(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canAddFile = (): boolean => {
|
||||
if (!limits || limits.unlimited) return true;
|
||||
return files.length < limits.max_count;
|
||||
};
|
||||
|
||||
const isFileSizeOk = (file: File): boolean => {
|
||||
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
|
||||
return file.size <= limits.max_size_per_file;
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (!limits?.unlimited && limits && limits.max_count > 0) {
|
||||
const remaining = limits.max_count - files.length;
|
||||
if (selected.length > remaining) {
|
||||
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
|
||||
setFileInputKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok: File[] = [];
|
||||
for (const f of selected) {
|
||||
if (!isFileSizeOk(f)) {
|
||||
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
|
||||
continue;
|
||||
}
|
||||
ok.push(f);
|
||||
}
|
||||
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
|
||||
setFileInputKey((k) => k + 1);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields().catch(() => null);
|
||||
if (!values || !values.message?.trim()) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
antMessage.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', values.message.trim());
|
||||
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
|
||||
}
|
||||
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limitHint =
|
||||
limits && !limits.unlimited
|
||||
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="message"
|
||||
label="Сообщение"
|
||||
rules={[{ required: true, message: 'Введите текст обращения' }]}
|
||||
>
|
||||
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="Тема (необязательно)">
|
||||
<Input placeholder="Краткая тема" maxLength={200} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Прикрепить файлы">
|
||||
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-attachments-input"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<label htmlFor="support-attachments-input">
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
|
||||
disabled={!canAddFile()}
|
||||
onClick={() => document.getElementById('support-attachments-input')?.click()}
|
||||
>
|
||||
Прикрепить файлы
|
||||
</Button>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => removeFile(i)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
ClipboardList,
|
||||
FileWarning,
|
||||
MessageCircle,
|
||||
Scale,
|
||||
} from 'lucide-react';
|
||||
import './HelloAuth.css';
|
||||
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
@@ -24,6 +26,8 @@ interface HelloAuthProps {
|
||||
|
||||
const INIT_DATA_WAIT_MS = 5500;
|
||||
const INIT_DATA_POLL_MS = 200;
|
||||
const INIT_DATA_BG_RECOVERY_MS = 15000;
|
||||
const INIT_DATA_BG_TICK_MS = 250;
|
||||
|
||||
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
@@ -59,6 +63,67 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
return null;
|
||||
};
|
||||
|
||||
const authWithInitData = async (channel: 'telegram' | 'max', initData: string): Promise<'success' | 'need_contact' | 'error'> => {
|
||||
miniappLog('auth_start', { channel, initDataLen: initData?.length ?? 0 });
|
||||
const res = await fetch('/api/v1/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel, init_data: initData }),
|
||||
});
|
||||
const data: Record<string, unknown> = await res.json().catch((e) => {
|
||||
miniappLog('auth_json_error', { err: String(e), status: res.status });
|
||||
return {};
|
||||
});
|
||||
miniappLog('auth_response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
keys: Object.keys(data),
|
||||
success: data.success,
|
||||
need_contact: data.need_contact,
|
||||
message: data.message,
|
||||
detail: data.detail,
|
||||
});
|
||||
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
|
||||
if (needContact) {
|
||||
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
|
||||
const doClose = () => {
|
||||
try {
|
||||
if (typeof webApp?.close === 'function') webApp.close();
|
||||
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
|
||||
} catch (_) {}
|
||||
};
|
||||
doClose();
|
||||
setTimeout(doClose, 200);
|
||||
return 'need_contact';
|
||||
}
|
||||
if (res.ok && data.success) {
|
||||
const token = data.session_token as string | undefined;
|
||||
if (token) {
|
||||
try {
|
||||
sessionStorage.setItem('session_token', token);
|
||||
localStorage.setItem('session_token', token); // запас для TG: WebView иногда теряет sessionStorage при переходах
|
||||
} catch (_) {}
|
||||
}
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
const avatarUrl = user?.photo_url || (data.avatar_url as string);
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return 'success';
|
||||
}
|
||||
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
|
||||
setStatus('error');
|
||||
void miniappSendLogs('auth_error');
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const tryAuth = async () => {
|
||||
setStatus('loading');
|
||||
setNoInitDataAfterTimeout(false);
|
||||
@@ -85,55 +150,63 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
}
|
||||
}
|
||||
if (channelInit) {
|
||||
const { channel, initData } = channelInit;
|
||||
const res = await fetch('/api/v1/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel, init_data: initData }),
|
||||
});
|
||||
const data: Record<string, unknown> = await res.json().catch(() => ({}));
|
||||
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
|
||||
if (needContact) {
|
||||
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
|
||||
const doClose = () => {
|
||||
try {
|
||||
if (typeof webApp?.close === 'function') webApp.close();
|
||||
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
|
||||
} catch (_) {}
|
||||
};
|
||||
doClose();
|
||||
setTimeout(doClose, 200);
|
||||
return;
|
||||
}
|
||||
if (res.ok && data.success) {
|
||||
const token = data.session_token as string | undefined;
|
||||
if (token) {
|
||||
try {
|
||||
sessionStorage.setItem('session_token', token);
|
||||
} catch (_) {}
|
||||
}
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
const avatarUrl = user?.photo_url || (data.avatar_url as string);
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
|
||||
setStatus('error');
|
||||
const result = await authWithInitData(channelInit.channel, channelInit.initData);
|
||||
if (result === 'success' || result === 'need_contact' || result === 'error') return;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) initData не появился за таймаут
|
||||
// 2) initData не появился за таймаут — пробуем восстановить сессию по session_token (после обновления страницы)
|
||||
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
|
||||
if (likelyMiniapp) {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
|
||||
} catch (_) {}
|
||||
if (token) {
|
||||
try {
|
||||
const verifyRes = await fetch('/api/v1/session/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: token }),
|
||||
});
|
||||
const verifyData = await verifyRes.json().catch(() => ({}));
|
||||
if (verifyData?.valid === true) {
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 2.5) TG после refresh иногда отдаёт initData с задержкой.
|
||||
// Фоном ждём ещё немного и тихо повторяем auth, не показывая сразу экран ошибки.
|
||||
const bgStart = Date.now();
|
||||
const bgDeadline = bgStart + INIT_DATA_BG_RECOVERY_MS;
|
||||
while (Date.now() < bgDeadline) {
|
||||
await new Promise((r) => setTimeout(r, INIT_DATA_BG_TICK_MS));
|
||||
const lateChannelInit = getChannelAndInitData();
|
||||
if (!lateChannelInit) continue;
|
||||
miniappLog('hello_init_data_recovered_bg', {
|
||||
waited_ms: Date.now() - bgStart,
|
||||
channel: lateChannelInit.channel,
|
||||
});
|
||||
const result = await authWithInitData(lateChannelInit.channel, lateChannelInit.initData);
|
||||
if (result === 'success' || result === 'need_contact' || result === 'error') return;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
url: window.location.href,
|
||||
hasTgWebApp: !!(window as any).Telegram?.WebApp,
|
||||
hasMaxWebApp: !!(window as any).WebApp,
|
||||
tgInitDataLen: typeof (window as any).Telegram?.WebApp?.initData === 'string' ? (window as any).Telegram.WebApp.initData.length : 0,
|
||||
maxInitDataLen: typeof (window as any).WebApp?.initData === 'string' ? (window as any).WebApp.initData.length : 0,
|
||||
};
|
||||
miniappLog('hello_no_init_data_after_timeout', ctx);
|
||||
miniappSendLogs('no_init_data_after_timeout').catch(() => {});
|
||||
setNoInitDataAfterTimeout(true);
|
||||
setStatus('idle');
|
||||
return;
|
||||
@@ -255,6 +328,7 @@ if (data.avatar_url) {
|
||||
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
|
||||
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
|
||||
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
|
||||
{ title: 'Экспертиза', icon: Scale, color: '#0EA5E9' },
|
||||
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
|
||||
{ title: 'Членство', icon: IdCard, color: '#10B981' },
|
||||
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
|
||||
|
||||
Reference in New Issue
Block a user