mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -1,5 +1,3 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
@@ -11,16 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
@@ -24,39 +24,10 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
@@ -31,3 +20,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
|
||||||
|
|||||||
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 { 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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user