diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a593c22..5c6ba58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,3 @@ -name: CI - on: push: branches: [main, dev] @@ -31,3 +29,15 @@ jobs: - name: Build run: npm run build + env: + VITE_API_URL: /api + VITE_TELEGRAM_BOT_USERNAME: test_bot + VITE_APP_NAME: Cabinet + VITE_APP_LOGO: V + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 7 diff --git a/src/App.tsx b/src/App.tsx index c99b572..48f2c17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { saveReturnUrl } from './utils/token' import Login from './pages/Login' import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' @@ -36,13 +37,16 @@ import AdminRemnawave from './pages/AdminRemnawave' function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } return {children} @@ -50,13 +54,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading, isAdmin } = useAuthStore() + const location = useLocation() if (isLoading) { return } if (!isAuthenticated) { - return + // Сохраняем текущий URL для возврата после авторизации + saveReturnUrl() + return } if (!isAdmin) { diff --git a/src/api/admin.ts b/src/api/admin.ts index acc3d91..cc935f6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -59,6 +59,8 @@ export interface TicketSettings { sla_check_interval_seconds: number sla_reminder_cooldown_minutes: number support_system_mode: string // tickets, contact, both + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean } export interface TicketSettingsUpdate { @@ -67,6 +69,8 @@ export interface TicketSettingsUpdate { sla_check_interval_seconds?: number sla_reminder_cooldown_minutes?: number support_system_mode?: string + cabinet_user_notifications_enabled?: boolean + cabinet_admin_notifications_enabled?: boolean } export interface AdminTicketListResponse { diff --git a/src/locales/en.json b/src/locales/en.json index 4e85c02..ab9d0ef 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,20 @@ "info": "Info", "wheel": "Fortune Wheel" }, + "notifications": { + "ticketNotifications": "Ticket Notifications", + "markAllRead": "Mark all read", + "noNotifications": "No notifications", + "justNow": "Just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} h ago", + "daysAgo": "{{count}} d ago", + "viewAll": "View all tickets", + "newNotification": "New notification", + "clickToView": "Click to view", + "newTicket": "New ticket: {{title}}", + "newReply": "New reply in ticket" + }, "auth": { "login": "Login", "register": "Register", @@ -732,7 +746,12 @@ "reminderCooldown": "Reminder interval (minutes)", "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", "settingsUpdateError": "Error saving settings", - "copyTelegramId": "Click to copy Telegram ID" + "copyTelegramId": "Click to copy Telegram ID", + "cabinetNotifications": "Cabinet Notifications", + "userNotificationsEnabled": "User Notifications", + "userNotificationsEnabledDesc": "Send notifications to users about admin replies", + "adminNotificationsEnabled": "Admin Notifications", + "adminNotificationsEnabledDesc": "Send notifications to admins about new tickets and replies" }, "tariffs": { "title": "Tariff Management", diff --git a/src/locales/ru.json b/src/locales/ru.json index e46a052..9cb1f19 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -35,6 +35,20 @@ "info": "Информация", "wheel": "Колесо удачи" }, + "notifications": { + "ticketNotifications": "Уведомления о тикетах", + "markAllRead": "Прочитать все", + "noNotifications": "Нет уведомлений", + "justNow": "Только что", + "minutesAgo": "{{count}} мин. назад", + "hoursAgo": "{{count}} ч. назад", + "daysAgo": "{{count}} д. назад", + "viewAll": "Все тикеты", + "newNotification": "Новое уведомление", + "clickToView": "Нажмите для просмотра", + "newTicket": "Новый тикет: {{title}}", + "newReply": "Новый ответ в тикете" + }, "auth": { "login": "Вход", "register": "Регистрация", @@ -732,7 +746,12 @@ "reminderCooldown": "Интервал напоминаний (минуты)", "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", "settingsUpdateError": "Ошибка сохранения настроек", - "copyTelegramId": "Нажмите чтобы скопировать Telegram ID" + "copyTelegramId": "Нажмите чтобы скопировать Telegram ID", + "cabinetNotifications": "Уведомления в кабинете", + "userNotificationsEnabled": "Уведомления для пользователей", + "userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа", + "adminNotificationsEnabled": "Уведомления для админов", + "adminNotificationsEnabledDesc": "Отправлять уведомления админам о новых тикетах и ответах" }, "tariffs": { "title": "Управление тарифами", diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 08a442f..99a87c7 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' import { banSystemApi, type BanSystemStatus, @@ -19,9 +18,9 @@ import { } from '../api/banSystem' // Icons -const BackIcon = () => ( - - +const ShieldIcon = () => ( + + ) @@ -397,12 +396,9 @@ export default function AdminBanSystem() { {/* Header */}
- - - +
+ +

{t('banSystem.title')}

{t('banSystem.subtitle')}

diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 9d6b622..1206b13 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -460,6 +460,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60, sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15, support_system_mode: settings?.support_system_mode ?? 'both', + cabinet_user_notifications_enabled: settings?.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings?.cabinet_admin_notifications_enabled ?? true, }) // Update form when settings load @@ -471,6 +473,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) { sla_check_interval_seconds: settings.sla_check_interval_seconds, sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes, support_system_mode: settings.support_system_mode, + cabinet_user_notifications_enabled: settings.cabinet_user_notifications_enabled ?? true, + cabinet_admin_notifications_enabled: settings.cabinet_admin_notifications_enabled ?? true, }) } }, [settings]) @@ -526,6 +530,43 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {

{t('admin.tickets.supportModeDesc')}

+ {/* Cabinet Notifications */} +
+

{t('admin.tickets.cabinetNotifications')}

+ + {/* User Notifications */} +
+ +
+ + {/* Admin Notifications */} +
+ +
+
+

{t('admin.tickets.slaSettings')}

diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index fd75065..c6e48ca 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' +import { useState, useEffect, useMemo, useCallback } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' import { brandingApi, type BrandingInfo } from '../api/branding' +import { getAndClearReturnUrl } from '../utils/token' import LanguageSwitcher from '../components/LanguageSwitcher' import TelegramLoginButton from '../components/TelegramLoginButton' @@ -46,6 +47,7 @@ const cacheBranding = (data: BrandingInfo) => { export default function Login() { const { t } = useTranslation() const navigate = useNavigate() + const location = useLocation() const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore() const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram') const [email, setEmail] = useState('') @@ -54,6 +56,22 @@ export default function Login() { const [isLoading, setIsLoading] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) + // Получаем URL для возврата после авторизации + const getReturnUrl = useCallback(() => { + // Сначала проверяем state от React Router + const stateFrom = (location.state as { from?: string })?.from + if (stateFrom && stateFrom !== '/login') { + return stateFrom + } + // Затем проверяем сохранённый URL в sessionStorage (от safeRedirectToLogin) + const savedUrl = getAndClearReturnUrl() + if (savedUrl && savedUrl !== '/login') { + return savedUrl + } + // По умолчанию на главную + return '/' + }, [location.state]) + // Fetch branding const cachedBranding = useMemo(() => getCachedBranding(), []) @@ -82,9 +100,9 @@ export default function Login() { useEffect(() => { if (isAuthenticated) { - navigate('/') + navigate(getReturnUrl(), { replace: true }) } - }, [isAuthenticated, navigate]) + }, [isAuthenticated, navigate, getReturnUrl]) // Try Telegram WebApp authentication on mount useEffect(() => { @@ -97,7 +115,7 @@ export default function Login() { setIsLoading(true) try { await loginWithTelegram(tg.initData) - navigate('/') + navigate(getReturnUrl(), { replace: true }) } catch (err) { console.error('Telegram auth failed:', err) setError(t('auth.telegramRequired')) @@ -108,7 +126,7 @@ export default function Login() { } tryTelegramAuth() - }, [loginWithTelegram, navigate, t]) + }, [loginWithTelegram, navigate, t, getReturnUrl]) const handleEmailLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -117,7 +135,7 @@ export default function Login() { try { await loginWithEmail(email, password) - navigate('/') + navigate(getReturnUrl(), { replace: true }) } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } setError(error.response?.data?.detail || t('common.error')) diff --git a/src/store/auth.ts b/src/store/auth.ts index 9799c21..a31b075 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -145,6 +145,8 @@ export const useAuthStore = create()( const newToken = await tokenRefreshManager.refreshAccessToken() if (newToken) { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken: newToken, refreshToken, @@ -152,7 +154,6 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } else { tokenStorage.clearTokens() set({ @@ -168,6 +169,8 @@ export const useAuthStore = create()( try { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken, refreshToken, @@ -175,13 +178,14 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } catch { // Token might be invalid on server, try to refresh const newToken = await tokenRefreshManager.refreshAccessToken() if (newToken) { try { const user = await authApi.getMe() + // Сначала проверяем admin статус, потом снимаем isLoading + await get().checkAdminStatus() set({ accessToken: newToken, refreshToken, @@ -189,7 +193,6 @@ export const useAuthStore = create()( isAuthenticated: true, isLoading: false, }) - get().checkAdminStatus() } catch { tokenStorage.clearTokens() set({ diff --git a/src/types/index.ts b/src/types/index.ts index d040af7..099f748 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -517,6 +517,7 @@ export interface ManualCheckResponse { new_status: string | null } +<<<<<<< HEAD // Ticket notification types export interface TicketNotification { id: number @@ -525,13 +526,42 @@ export interface TicketNotification { message: string is_read: boolean created_at: string +======= +// Ticket notifications types +export interface TicketNotification { + id: number + ticket_id: number + notification_type: 'new_ticket' | 'admin_reply' | 'user_reply' + message: string | null + is_read: boolean + created_at: string + read_at: string | null +>>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 } export interface TicketNotificationList { items: TicketNotification[] +<<<<<<< HEAD total: number +======= + unread_count: number +>>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 } export interface UnreadCountResponse { unread_count: number } +<<<<<<< HEAD +======= + +// Extended TicketSettings with cabinet notifications +export interface TicketSettings { + sla_enabled: boolean + sla_minutes: number + sla_check_interval_seconds: number + sla_reminder_cooldown_minutes: number + support_system_mode: string + cabinet_user_notifications_enabled: boolean + cabinet_admin_notifications_enabled: boolean +} +>>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076 diff --git a/src/utils/token.ts b/src/utils/token.ts index 62f32dc..eb0d254 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -252,9 +252,40 @@ class TokenRefreshManager { export const tokenRefreshManager = new TokenRefreshManager() +/** + * Ключ для сохранения URL для возврата после логина + */ +const RETURN_URL_KEY = 'auth_return_url' + +/** + * Сохраняет URL для возврата после авторизации + */ +export function saveReturnUrl(): void { + if (typeof window !== 'undefined') { + const currentPath = window.location.pathname + window.location.search + // Не сохраняем /login как return URL + if (currentPath && currentPath !== '/login') { + sessionStorage.setItem(RETURN_URL_KEY, currentPath) + } + } +} + +/** + * Получает и очищает сохранённый URL для возврата + */ +export function getAndClearReturnUrl(): string | null { + if (typeof window !== 'undefined') { + const url = sessionStorage.getItem(RETURN_URL_KEY) + sessionStorage.removeItem(RETURN_URL_KEY) + return url + } + return null +} + /** * Безопасный редирект на страницу логина * Валидирует URL чтобы предотвратить open redirect уязвимость + * Сохраняет текущий URL для возврата после авторизации */ export function safeRedirectToLogin(): void { // Разрешённые пути для редиректа (относительные пути нашего приложения) @@ -262,6 +293,8 @@ export function safeRedirectToLogin(): void { // Проверяем, что мы на том же origin if (typeof window !== 'undefined') { + // Сохраняем текущий URL для возврата после логина + saveReturnUrl() // Используем только относительный путь для безопасности window.location.href = loginPath }