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: on:
push: push:
branches: [main, dev] branches: [main, dev]
@@ -31,3 +29,15 @@ jobs:
- name: Build - name: Build
run: npm run 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 { useAuthStore } from './store/auth'
import Layout from './components/layout/Layout' import Layout from './components/layout/Layout'
import PageLoader from './components/common/PageLoader' import PageLoader from './components/common/PageLoader'
import { saveReturnUrl } from './utils/token'
import Login from './pages/Login' import Login from './pages/Login'
import TelegramCallback from './pages/TelegramCallback' import TelegramCallback from './pages/TelegramCallback'
import TelegramRedirect from './pages/TelegramRedirect' import TelegramRedirect from './pages/TelegramRedirect'
@@ -36,13 +37,16 @@ import AdminRemnawave from './pages/AdminRemnawave'
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore() const { isAuthenticated, isLoading } = useAuthStore()
const location = useLocation()
if (isLoading) { if (isLoading) {
return <PageLoader variant="dark" /> return <PageLoader variant="dark" />
} }
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate to="/login" replace /> // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl()
return <Navigate to="/login" replace state={{ from: location.pathname }} />
} }
return <Layout>{children}</Layout> return <Layout>{children}</Layout>
@@ -50,13 +54,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
function AdminRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, isAdmin } = useAuthStore() const { isAuthenticated, isLoading, isAdmin } = useAuthStore()
const location = useLocation()
if (isLoading) { if (isLoading) {
return <PageLoader variant="light" /> return <PageLoader variant="light" />
} }
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate to="/login" replace /> // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl()
return <Navigate to="/login" replace state={{ from: location.pathname }} />
} }
if (!isAdmin) { if (!isAdmin) {

View File

@@ -59,6 +59,8 @@ export interface TicketSettings {
sla_check_interval_seconds: number sla_check_interval_seconds: number
sla_reminder_cooldown_minutes: number sla_reminder_cooldown_minutes: number
support_system_mode: string // tickets, contact, both support_system_mode: string // tickets, contact, both
cabinet_user_notifications_enabled: boolean
cabinet_admin_notifications_enabled: boolean
} }
export interface TicketSettingsUpdate { export interface TicketSettingsUpdate {
@@ -67,6 +69,8 @@ export interface TicketSettingsUpdate {
sla_check_interval_seconds?: number sla_check_interval_seconds?: number
sla_reminder_cooldown_minutes?: number sla_reminder_cooldown_minutes?: number
support_system_mode?: string support_system_mode?: string
cabinet_user_notifications_enabled?: boolean
cabinet_admin_notifications_enabled?: boolean
} }
export interface AdminTicketListResponse { export interface AdminTicketListResponse {

View File

@@ -35,6 +35,20 @@
"info": "Info", "info": "Info",
"wheel": "Fortune Wheel" "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": { "auth": {
"login": "Login", "login": "Login",
"register": "Register", "register": "Register",
@@ -732,7 +746,12 @@
"reminderCooldown": "Reminder interval (minutes)", "reminderCooldown": "Reminder interval (minutes)",
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
"settingsUpdateError": "Error saving settings", "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": { "tariffs": {
"title": "Tariff Management", "title": "Tariff Management",

View File

@@ -35,6 +35,20 @@
"info": "Информация", "info": "Информация",
"wheel": "Колесо удачи" "wheel": "Колесо удачи"
}, },
"notifications": {
"ticketNotifications": "Уведомления о тикетах",
"markAllRead": "Прочитать все",
"noNotifications": "Нет уведомлений",
"justNow": "Только что",
"minutesAgo": "{{count}} мин. назад",
"hoursAgo": "{{count}} ч. назад",
"daysAgo": "{{count}} д. назад",
"viewAll": "Все тикеты",
"newNotification": "Новое уведомление",
"clickToView": "Нажмите для просмотра",
"newTicket": "Новый тикет: {{title}}",
"newReply": "Новый ответ в тикете"
},
"auth": { "auth": {
"login": "Вход", "login": "Вход",
"register": "Регистрация", "register": "Регистрация",
@@ -732,7 +746,12 @@
"reminderCooldown": "Интервал напоминаний (минуты)", "reminderCooldown": "Интервал напоминаний (минуты)",
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
"settingsUpdateError": "Ошибка сохранения настроек", "settingsUpdateError": "Ошибка сохранения настроек",
"copyTelegramId": "Нажмите чтобы скопировать Telegram ID" "copyTelegramId": "Нажмите чтобы скопировать Telegram ID",
"cabinetNotifications": "Уведомления в кабинете",
"userNotificationsEnabled": "Уведомления для пользователей",
"userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа",
"adminNotificationsEnabled": "Уведомления для админов",
"adminNotificationsEnabledDesc": "Отправлять уведомления админам о новых тикетах и ответах"
}, },
"tariffs": { "tariffs": {
"title": "Управление тарифами", "title": "Управление тарифами",

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { import {
banSystemApi, banSystemApi,
type BanSystemStatus, type BanSystemStatus,
@@ -19,9 +18,9 @@ import {
} from '../api/banSystem' } from '../api/banSystem'
// Icons // Icons
const BackIcon = () => ( const ShieldIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.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> </svg>
) )
@@ -397,12 +396,9 @@ export default function AdminBanSystem() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <div className="p-3 bg-error-500/20 rounded-xl">
to="/admin" <ShieldIcon />
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" </div>
>
<BackIcon />
</Link>
<div> <div>
<h1 className="text-2xl font-bold text-dark-100">{t('banSystem.title')}</h1> <h1 className="text-2xl font-bold text-dark-100">{t('banSystem.title')}</h1>
<p className="text-dark-400">{t('banSystem.subtitle')}</p> <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_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60,
sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15, sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15,
support_system_mode: settings?.support_system_mode ?? 'both', 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 // Update form when settings load
@@ -471,6 +473,8 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
sla_check_interval_seconds: settings.sla_check_interval_seconds, sla_check_interval_seconds: settings.sla_check_interval_seconds,
sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes, sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes,
support_system_mode: settings.support_system_mode, 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]) }, [settings])
@@ -526,6 +530,43 @@ function TicketSettingsModal({ onClose }: { onClose: () => void }) {
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p> <p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
</div> </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"> <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> <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 { useState, useEffect, useMemo, useCallback } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import { brandingApi, type BrandingInfo } from '../api/branding' import { brandingApi, type BrandingInfo } from '../api/branding'
import { getAndClearReturnUrl } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher' import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton' import TelegramLoginButton from '../components/TelegramLoginButton'
@@ -46,6 +47,7 @@ const cacheBranding = (data: BrandingInfo) => {
export default function Login() { export default function Login() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore() const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore()
const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram') const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -54,6 +56,22 @@ export default function Login() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = 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 // Fetch branding
const cachedBranding = useMemo(() => getCachedBranding(), []) const cachedBranding = useMemo(() => getCachedBranding(), [])
@@ -82,9 +100,9 @@ export default function Login() {
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
navigate('/') navigate(getReturnUrl(), { replace: true })
} }
}, [isAuthenticated, navigate]) }, [isAuthenticated, navigate, getReturnUrl])
// Try Telegram WebApp authentication on mount // Try Telegram WebApp authentication on mount
useEffect(() => { useEffect(() => {
@@ -97,7 +115,7 @@ export default function Login() {
setIsLoading(true) setIsLoading(true)
try { try {
await loginWithTelegram(tg.initData) await loginWithTelegram(tg.initData)
navigate('/') navigate(getReturnUrl(), { replace: true })
} catch (err) { } catch (err) {
console.error('Telegram auth failed:', err) console.error('Telegram auth failed:', err)
setError(t('auth.telegramRequired')) setError(t('auth.telegramRequired'))
@@ -108,7 +126,7 @@ export default function Login() {
} }
tryTelegramAuth() tryTelegramAuth()
}, [loginWithTelegram, navigate, t]) }, [loginWithTelegram, navigate, t, getReturnUrl])
const handleEmailLogin = async (e: React.FormEvent) => { const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -117,7 +135,7 @@ export default function Login() {
try { try {
await loginWithEmail(email, password) await loginWithEmail(email, password)
navigate('/') navigate(getReturnUrl(), { replace: true })
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error')) setError(error.response?.data?.detail || t('common.error'))

View File

@@ -145,6 +145,8 @@ export const useAuthStore = create<AuthState>()(
const newToken = await tokenRefreshManager.refreshAccessToken() const newToken = await tokenRefreshManager.refreshAccessToken()
if (newToken) { if (newToken) {
const user = await authApi.getMe() const user = await authApi.getMe()
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus()
set({ set({
accessToken: newToken, accessToken: newToken,
refreshToken, refreshToken,
@@ -152,7 +154,6 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
get().checkAdminStatus()
} else { } else {
tokenStorage.clearTokens() tokenStorage.clearTokens()
set({ set({
@@ -168,6 +169,8 @@ export const useAuthStore = create<AuthState>()(
try { try {
const user = await authApi.getMe() const user = await authApi.getMe()
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus()
set({ set({
accessToken, accessToken,
refreshToken, refreshToken,
@@ -175,13 +178,14 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
get().checkAdminStatus()
} catch { } catch {
// Token might be invalid on server, try to refresh // Token might be invalid on server, try to refresh
const newToken = await tokenRefreshManager.refreshAccessToken() const newToken = await tokenRefreshManager.refreshAccessToken()
if (newToken) { if (newToken) {
try { try {
const user = await authApi.getMe() const user = await authApi.getMe()
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus()
set({ set({
accessToken: newToken, accessToken: newToken,
refreshToken, refreshToken,
@@ -189,7 +193,6 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
get().checkAdminStatus()
} catch { } catch {
tokenStorage.clearTokens() tokenStorage.clearTokens()
set({ set({

View File

@@ -517,6 +517,7 @@ export interface ManualCheckResponse {
new_status: string | null new_status: string | null
} }
<<<<<<< HEAD
// Ticket notification types // Ticket notification types
export interface TicketNotification { export interface TicketNotification {
id: number id: number
@@ -525,13 +526,42 @@ export interface TicketNotification {
message: string message: string
is_read: boolean is_read: boolean
created_at: string 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 { export interface TicketNotificationList {
items: TicketNotification[] items: TicketNotification[]
<<<<<<< HEAD
total: number total: number
=======
unread_count: number
>>>>>>> af948391078db4bb97a333feb7e0ddfcf638b076
} }
export interface UnreadCountResponse { export interface UnreadCountResponse {
unread_count: number 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() 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 чтобы предотвратить open redirect уязвимость
* Сохраняет текущий URL для возврата после авторизации
*/ */
export function safeRedirectToLogin(): void { export function safeRedirectToLogin(): void {
// Разрешённые пути для редиректа (относительные пути нашего приложения) // Разрешённые пути для редиректа (относительные пути нашего приложения)
@@ -262,6 +293,8 @@ export function safeRedirectToLogin(): void {
// Проверяем, что мы на том же origin // Проверяем, что мы на том же origin
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Сохраняем текущий URL для возврата после логина
saveReturnUrl()
// Используем только относительный путь для безопасности // Используем только относительный путь для безопасности
window.location.href = loginPath window.location.href = loginPath
} }