diff --git a/.env.example b/.env.example index 075c1d5..b91993a 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,11 @@ FCM_ENABLED=false FCM_TOPIC=route_alerts FCM_ANDROID_CHANNEL_ID=route_alerts FIREBASE_PROJECT_ID= -# Standard Application Default Credentials path. Store the JSON file outside -# the repository and leave this empty while FCM_ENABLED=false. -GOOGLE_APPLICATION_CREDENTIALS= +# Directory on the Docker host (VM/LXC inside Proxmox), mounted read-only. +FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets +# Path inside the backend container. For a direct Node.js launch, replace it +# with the absolute host path to the JSON file. +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json # Bootstrap admin BOOTSTRAP_ADMIN=true diff --git a/.gitignore b/.gitignore index 0624a34..ea7eef0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ redis_data/ # SSL certificates nginx/ssl/ +# Runtime secrets +secrets/ + # Temp files *.tmp .cache/ diff --git a/backend/src/services/alertNotificationService.js b/backend/src/services/alertNotificationService.js index 5b14f8a..b482904 100644 --- a/backend/src/services/alertNotificationService.js +++ b/backend/src/services/alertNotificationService.js @@ -1,6 +1,5 @@ const { DEFAULT_FCM_TOPIC, - DEFAULT_FCM_ANDROID_CHANNEL_ID, getFirebaseConfig, getFirebaseMessaging } = require('../config/firebase'); @@ -91,14 +90,9 @@ function isAlertActive(alert = {}) { function buildAlertCreatedMessage(alert, options = {}) { const alertId = getAlertId(alert); const topic = options.topic || DEFAULT_FCM_TOPIC; - const channelId = options.androidChannelId || options.channelId || DEFAULT_FCM_ANDROID_CHANNEL_ID; return { topic, - notification: { - title: getNotificationTitle(alert), - body: getNotificationBody(alert) - }, data: { event: 'alert_created', alertId, @@ -106,14 +100,12 @@ function buildAlertCreatedMessage(alert, options = {}) { routeId: getRouteId(alert), routeNumber: getRouteNumber(alert), startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)), - endTime: toIsoString(firstDefined(alert.end_time, alert.endTime)) + endTime: toIsoString(firstDefined(alert.end_time, alert.endTime)), + title: getNotificationTitle(alert), + body: getNotificationBody(alert) }, android: { - priority: 'high', - notification: { - channelId, - tag: `alert_${alertId}` - } + priority: 'high' } }; } @@ -131,14 +123,12 @@ class AlertNotificationService { this.enabled = options.enabled === undefined ? firebaseConfig.enabled : options.enabled; this.topic = options.topic || firebaseConfig.topic; - this.androidChannelId = options.androidChannelId || options.channelId || firebaseConfig.androidChannelId; this.logger = options.logger || getDefaultLogger(); this.getMessaging = options.getMessaging || options.messagingFactory || (() => ( options.messaging || getFirebaseMessaging({ ...firebaseConfig, enabled: this.enabled, - topic: this.topic, - androidChannelId: this.androidChannelId + topic: this.topic }) )); } @@ -160,10 +150,7 @@ class AlertNotificationService { throw new Error('Firebase Messaging is unavailable'); } - const message = buildAlertCreatedMessage(alert, { - topic: this.topic, - androidChannelId: this.androidChannelId - }); + const message = buildAlertCreatedMessage(alert, { topic: this.topic }); const messageId = await messaging.send(message); const normalizedMessageId = toDataString(messageId); diff --git a/backend/test/alertNotificationService.test.js b/backend/test/alertNotificationService.test.js index ed263b5..30bca0c 100644 --- a/backend/test/alertNotificationService.test.js +++ b/backend/test/alertNotificationService.test.js @@ -18,7 +18,7 @@ function createLogger() { }; } -test('sends an exact alert-created notification and string-only data payload', async () => { +test('sends an exact data-only alert-created message with string-only data', async () => { const messages = []; const service = new AlertNotificationService({ enabled: true, @@ -52,10 +52,6 @@ test('sends an exact alert-created notification and string-only data payload', a assert.equal(messages.length, 1); assert.deepEqual(messages[0], { topic: 'route_alerts', - notification: { - title: 'Автобус задерживается', - body: 'Задержка около 15 минут' - }, data: { event: 'alert_created', alertId: '42', @@ -63,16 +59,19 @@ test('sends an exact alert-created notification and string-only data payload', a routeId: '7', routeNumber: '12', startTime: '2026-07-11T08:30:00.000Z', - endTime: '2026-07-11T09:30:00.000Z' + endTime: '2026-07-11T09:30:00.000Z', + title: 'Автобус задерживается', + body: 'Задержка около 15 минут' }, android: { - priority: 'high', - notification: { - channelId: 'route_alerts', - tag: 'alert_42' - } + priority: 'high' } }); + assert.equal('notification' in messages[0], false); + assert.equal('notification' in messages[0].android, false); + assert.equal(messages[0].android.priority, 'high'); + assert.equal(messages[0].data.title, 'Автобус задерживается'); + assert.equal(messages[0].data.body, 'Задержка около 15 минут'); assert.ok(Object.values(messages[0].data).every((value) => typeof value === 'string')); }); @@ -92,6 +91,10 @@ test('serializes a null route id and missing optional values as empty strings', assert.equal(message.data.routeNumber, ''); assert.equal(message.data.startTime, ''); assert.equal(message.data.endTime, ''); + assert.equal(message.data.title, 'Новость'); + assert.equal(message.data.body, 'Важная информация'); + assert.equal('notification' in message, false); + assert.equal('notification' in message.android, false); }); test('does not initialize messaging or send for an inactive alert', async () => { @@ -207,8 +210,8 @@ test('uses the requested Russian title fallbacks and a route-aware body fallback route_number: '5' }); - assert.equal(message.notification.title, expectedTitle); - assert.equal(message.notification.body, 'Актуальная информация по маршруту №5'); + assert.equal(message.data.title, expectedTitle); + assert.equal(message.data.body, 'Актуальная информация по маршруту №5'); } const withoutRoute = buildAlertCreatedMessage({ @@ -217,7 +220,7 @@ test('uses the requested Russian title fallbacks and a route-aware body fallback title: '', message: '' }); - assert.equal(withoutRoute.notification.body, 'Актуальная информация о движении транспорта'); + assert.equal(withoutRoute.data.body, 'Актуальная информация о движении транспорта'); }); test('reads disabled-by-default Firebase configuration and environment overrides', () => { diff --git a/docker-compose.yml b/docker-compose.yml index e52ba3b..8054d8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: FCM_TOPIC: "${FCM_TOPIC:-route_alerts}" FCM_ANDROID_CHANNEL_ID: "${FCM_ANDROID_CHANNEL_ID:-route_alerts}" FIREBASE_PROJECT_ID: "${FIREBASE_PROJECT_ID:-}" - GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-}" + GOOGLE_APPLICATION_CREDENTIALS: "${GOOGLE_APPLICATION_CREDENTIALS:-/run/secrets/firebase-service-account.json}" ports: - "3000:3000" depends_on: @@ -73,6 +73,7 @@ services: volumes: - ./backend/logs:/app/logs - avatars_data:/app/public/uploads/avatars + - ${FIREBASE_SECRETS_DIR:-./secrets}:/run/secrets:ro frontend: build: diff --git a/docs/fcm-alert-notifications.md b/docs/fcm-alert-notifications.md index 22b7536..671b795 100644 --- a/docs/fcm-alert-notifications.md +++ b/docs/fcm-alert-notifications.md @@ -155,45 +155,48 @@ GOOGLE_APPLICATION_CREDENTIALS=/absolute/private/path/firebase-service-account.j После этого запустите `npm run dev`. Не копируйте JSON в `backend/`, не добавляйте его в `.env` в виде текста и не коммитьте файл. -### Локальный запуск в Docker Compose +### Запуск в Docker Compose на Proxmox -Значение `GOOGLE_APPLICATION_CREDENTIALS` внутри контейнера должно указывать на -путь внутри контейнера, поэтому одного host-пути в корневом `.env` недостаточно. -Создайте локальный, не коммитящийся файл -`docker-compose.firebase.override.yml`: - -```yaml -services: - backend: - environment: - FCM_ENABLED: "true" - FIREBASE_PROJECT_ID: ridebus-development - GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json - volumes: - - /absolute/private/path/firebase-service-account.json:/run/secrets/firebase-service-account.json:ro -``` - -Запуск: +Docker должен работать внутри VM или LXC-контейнера Proxmox. На этом Docker- +хосте создайте закрытый каталог и положите в него service account JSON: ```bash -docker compose \ - -f docker-compose.yml \ - -f docker-compose.firebase.override.yml \ - up -d --build backend +sudo install -d -m 700 /opt/polotsk-transit/secrets +sudo install -m 600 /private/path/firebase-service-account.json \ + /opt/polotsk-transit/secrets/firebase-service-account.json ``` -Основной `docker-compose.yml` передаёт все FCM-переменные, но не требует и не -монтирует credential-файл. Поэтому обычный запуск с `FCM_ENABLED=false` -остается рабочим без Firebase. +В корневом `.env` проекта задайте: + +```env +FCM_ENABLED=true +FCM_TOPIC=route_alerts +FCM_ANDROID_CHANNEL_ID=route_alerts +FIREBASE_PROJECT_ID=ridebus-production +FIREBASE_SECRETS_DIR=/opt/polotsk-transit/secrets +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/firebase-service-account.json +``` + +Основной `docker-compose.yml` монтирует `FIREBASE_SECRETS_DIR` в backend как +read-only `/run/secrets`. Запуск отдельного override-файла больше не требуется: + +```bash +docker compose up -d --build backend +docker compose logs -f backend +``` + +При `FCM_ENABLED=false` Firebase не инициализируется, поэтому отсутствие JSON +не мешает работе приложения. ## Формирование сообщения -FCM получает одновременно видимый `notification` payload и строковый `data` -payload. Android priority всегда `high`. Все значения в `data`, включая ID и -даты, преобразуются в строки; отсутствующие значения передаются как пустые -строки. +FCM получает только строковый `data` payload. Верхнеуровневого `notification` +и `android.notification` нет, поэтому Android-клиент сам создаёт уведомление и +может добавить локальные действия, включая кнопку «Прочитано». Android +priority всегда `high`. Все значения в `data`, включая ID, даты, заголовок и +текст, являются строками; отсутствующие значения передаются как пустые строки. -`notification.title` берётся из `alert.title`. Если заголовок отсутствует или +`data.title` берётся из `alert.title`. Если заголовок отсутствует или состоит только из пробелов, используется fallback по типу: | `alert_type` | Fallback title | @@ -203,7 +206,7 @@ payload. Android priority всегда `high`. Все значения в `data` | `detour` | `Изменение маршрута` | | `info` | `Информация о маршруте` | -`notification.body` содержит `alert.message`. Для пустого сообщения +`data.body` содержит `alert.message`. Для пустого сообщения используется безопасный текст с номером маршрута, а если маршрут не указан — общий текст об обновлении движения. @@ -212,10 +215,6 @@ payload. Android priority всегда `high`. Все значения в `data` ```json { "topic": "route_alerts", - "notification": { - "title": "Задержка движения", - "body": "Автобусы задерживаются примерно на 15 минут" - }, "data": { "event": "alert_created", "alertId": "42", @@ -223,14 +222,12 @@ payload. Android priority всегда `high`. Все значения в `data` "routeId": "7", "routeNumber": "5", "startTime": "2026-07-11T09:00:00.000Z", - "endTime": "2026-07-11T11:00:00.000Z" + "endTime": "2026-07-11T11:00:00.000Z", + "title": "Задержка движения", + "body": "Автобусы задерживаются примерно на 15 минут" }, "android": { - "priority": "high", - "notification": { - "channelId": "route_alerts", - "tag": "alert_42" - } + "priority": "high" } } ``` @@ -295,5 +292,5 @@ curl -i -X POST http://localhost:3000/api/v1/alerts \ вернуть `201`, а лог должен содержать `alertId`, Firebase code и причину без credentials или токенов. -Поведение Android при foreground/background зависит от клиентской реализации, -но серверный контракт всегда содержит и `notification`, и `data` payload. +Поведение Android при foreground/background полностью определяется клиентской +обработкой data-only сообщения. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 15b0b84..7f073df 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,9 +1,8 @@ -import { useState } from 'react'; -import { BrowserRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'; -import { Bus, MapPin, Calendar, Settings, Gift, Menu, X, AlertCircle, Users, UserCircle, LogOut } from 'lucide-react'; +import { BrowserRouter as Router, Routes, Route, useLocation, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ProtectedRoute } from './components/ProtectedRoute'; -import { Avatar } from './components/AvatarUpload'; +import AppLayout from './components/AppLayout'; +import { PageLoader } from './components/AnimatedComponents'; // Pages import LoginPage from './pages/LoginPage'; @@ -28,7 +27,9 @@ function App() { } function AppContent() { - const { isAuthenticated } = useAuth(); + const { isAuthenticated, loading } = useAuth(); + + if (loading) return ; if (!isAuthenticated) { return ( @@ -40,15 +41,9 @@ function AppContent() { } return ( -
- - -
- -
- - -
+ + + ); } @@ -76,206 +71,4 @@ function AnimatedRoutes() { ); } -function Navigation() { - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [showUserMenu, setShowUserMenu] = useState(false); - const { user, logout, isAdmin } = useAuth(); - - return ( - - ); -} - -function MobileBottomNav() { - const location = useLocation(); - const { isAdmin } = useAuth(); - - const navItems = [ - { to: '/', icon: Settings, label: 'Главная' }, - { to: '/routes', icon: Bus, label: 'Маршруты' }, - { to: '/stops', icon: MapPin, label: 'Остановки' }, - { to: '/alerts', icon: AlertCircle, label: 'Уведомл.' }, - isAdmin - ? { to: '/users', icon: Users, label: 'Пользов.' } - : { to: '/profile', icon: UserCircle, label: 'Профиль' }, - ]; - - return ( -
-
- {navItems.map(({ to, icon: Icon, label }) => { - const isActive = location.pathname === to || - (to !== '/' && location.pathname.startsWith(to)); - - return ( - - - {label} - {isActive && ( -
- )} - - ); - })} -
-
- ); -} - -function NavLink({ to, icon, children }) { - const location = useLocation(); - const isActive = location.pathname === to || - (to !== '/' && location.pathname.startsWith(to)); - - return ( - - {icon} - {children} - - ); -} - -function MobileNavLink({ to, icon, children, onClick, delay = 0 }) { - const location = useLocation(); - const isActive = location.pathname === to || - (to !== '/' && location.pathname.startsWith(to)); - - return ( - - {icon} - {children} - - ); -} - -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/assets/dashboard-background.png b/frontend/src/assets/dashboard-background.png new file mode 100644 index 0000000..82e6c05 Binary files /dev/null and b/frontend/src/assets/dashboard-background.png differ diff --git a/frontend/src/assets/logo-with-text.svg b/frontend/src/assets/logo-with-text.svg new file mode 100644 index 0000000..c457564 --- /dev/null +++ b/frontend/src/assets/logo-with-text.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx new file mode 100644 index 0000000..81a997e --- /dev/null +++ b/frontend/src/components/AppLayout.jsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, NavLink, useLocation } from 'react-router-dom'; +import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import BrandLogo from './BrandLogo'; +import ProfileMenu from './ProfileMenu'; + +const navigationGroups = [ + { label: 'Обзор', items: [{ to: '/', label: 'Главная', icon: LayoutDashboard }] }, + { label: 'Транспорт', items: [ + { to: '/routes', label: 'Маршруты', icon: Bus }, + { to: '/stops', label: 'Остановки', icon: MapPin }, + { to: '/schedules', label: 'Расписания', icon: CalendarDays }, + { to: '/holidays', label: 'Праздники', icon: Gift }, + ] }, + { label: 'Управление', items: [ + { to: '/alerts', label: 'Уведомления', icon: Bell }, + { to: '/users', label: 'Пользователи', icon: Users, adminOnly: true }, + { to: '/profile', label: 'Профиль', icon: User }, + ] }, +]; + +export default function AppLayout({ children }) { + const { isAdmin } = useAuth(); + const { pathname } = useLocation(); + const [menuOpen, setMenuOpen] = useState(false); + const menuButton = useRef(null); + + useEffect(() => { setMenuOpen(false); }, [pathname]); + + function closeMenu() { + setMenuOpen(false); + menuButton.current?.focus(); + } + + return ( +
+ Перейти к содержимому + +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/BrandLogo.jsx b/frontend/src/components/BrandLogo.jsx new file mode 100644 index 0000000..af25154 --- /dev/null +++ b/frontend/src/components/BrandLogo.jsx @@ -0,0 +1,5 @@ +import logo from '../assets/logo-with-text.svg'; + +export default function BrandLogo({ className = '' }) { + return RideBus; +} diff --git a/frontend/src/components/ProfileMenu.jsx b/frontend/src/components/ProfileMenu.jsx new file mode 100644 index 0000000..6197b4c --- /dev/null +++ b/frontend/src/components/ProfileMenu.jsx @@ -0,0 +1,117 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Link, useLocation } from 'react-router-dom'; +import { ChevronDown, LogOut, User } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { Avatar } from './AvatarUpload'; + +export default function ProfileMenu() { + const { user, isAdmin, logout } = useAuth(); + const { pathname } = useLocation(); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({}); + const trigger = useRef(null); + const menu = useRef(null); + const focusLast = useRef(false); + + function close(restoreFocus = false) { + setOpen(false); + if (restoreFocus) trigger.current?.focus(); + } + + useEffect(() => { setOpen(false); }, [pathname]); + + useLayoutEffect(() => { + if (!open) return; + function placeMenu() { + const rect = trigger.current.getBoundingClientRect(); + if (!rect.width) { setOpen(false); return; } + const width = Math.min(Math.max(rect.width, 248), window.innerWidth - 32); + const height = menu.current?.offsetHeight || 164; + setPosition({ + width, + left: Math.max(16, Math.min(rect.left, window.innerWidth - width - 16)), + top: rect.bottom + height + 8 < window.innerHeight + ? rect.bottom + 8 : Math.max(8, rect.top - height - 8), + }); + } + placeMenu(); + window.addEventListener('resize', placeMenu); + window.addEventListener('scroll', placeMenu, true); + return () => { + window.removeEventListener('resize', placeMenu); + window.removeEventListener('scroll', placeMenu, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const items = menu.current.querySelectorAll('[role="menuitem"]'); + items[focusLast.current ? items.length - 1 : 0]?.focus(); + function dismiss(event) { + if (!menu.current?.contains(event.target) && !trigger.current?.contains(event.target)) close(); + } + document.addEventListener('pointerdown', dismiss); + document.addEventListener('focusin', dismiss); + return () => { + document.removeEventListener('pointerdown', dismiss); + document.removeEventListener('focusin', dismiss); + }; + }, [open]); + + function onMenuKeyDown(event) { + const items = [...menu.current.querySelectorAll('[role="menuitem"]')]; + const current = items.indexOf(document.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + close(true); + } else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[next]?.focus(); + } else if (event.key === 'Tab') { + // Return to the trigger's position in the document tab order before moving on. + close(true); + } + } + + return ( + <> + + {open && createPortal( +