mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
13
src/App.tsx
13
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 <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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Управление тарифами",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user