Enhance CI workflow with environment variables for build and add artifact upload step. Update ProtectedRoute and AdminRoute to save return URL for authentication. Introduce cabinet notification settings in admin ticket management and update localization files for new notification strings.

This commit is contained in:
PEDZEO
2026-01-19 00:43:29 +03:00
11 changed files with 207 additions and 27 deletions

View File

@@ -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

View File

@@ -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 <PageLoader variant="dark" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl()
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}
return <Layout>{children}</Layout>
@@ -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 <PageLoader variant="light" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl()
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}
if (!isAdmin) {

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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": "Управление тарифами",

View File

@@ -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 = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
const ShieldIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
@@ -397,12 +396,9 @@ export default function AdminBanSystem() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
>
<BackIcon />
</Link>
<div className="p-3 bg-error-500/20 rounded-xl">
<ShieldIcon />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('banSystem.title')}</h1>
<p className="text-dark-400">{t('banSystem.subtitle')}</p>

View File

@@ -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 }) {
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
</div>
{/* Cabinet Notifications */}
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.cabinetNotifications')}</h3>
{/* User Notifications */}
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.cabinet_user_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_user_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.userNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.userNotificationsEnabledDesc')}</div>
</div>
</label>
</div>
{/* Admin Notifications */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.cabinet_admin_notifications_enabled}
onChange={(e) => setFormData({ ...formData, cabinet_admin_notifications_enabled: e.target.checked })}
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
/>
<div>
<div className="text-dark-100 font-medium">{t('admin.tickets.adminNotificationsEnabled')}</div>
<div className="text-sm text-dark-500">{t('admin.tickets.adminNotificationsEnabledDesc')}</div>
</div>
</label>
</div>
</div>
<div className="border-t border-dark-800/50 pt-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.slaSettings')}</h3>

View File

@@ -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'))

View File

@@ -145,6 +145,8 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
isAuthenticated: true,
isLoading: false,
})
get().checkAdminStatus()
} else {
tokenStorage.clearTokens()
set({
@@ -168,6 +169,8 @@ export const useAuthStore = create<AuthState>()(
try {
const user = await authApi.getMe()
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus()
set({
accessToken,
refreshToken,
@@ -175,13 +178,14 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
isAuthenticated: true,
isLoading: false,
})
get().checkAdminStatus()
} catch {
tokenStorage.clearTokens()
set({

View File

@@ -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

View File

@@ -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
}