diff --git a/.env.example b/.env.example index 99d7ab5..5e346c6 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ TELEGRAM_SESSION_PATH=/data/telegram.session INTERNAL_TOKEN=dev-internal-token TEST_PI_API_KEY=test-pi-api-key-change-me +# Admin UI login (public map stays open; /admin and object writes need JWT) +ADMIN_USER=admin +ADMIN_PASSWORD=change-me +ADMIN_JWT_SECRET=change-me-jwt-secret + # DeepSeek LLM: # - extract_mode=llm on CP workers (Telegram unstructured + Crawl4AI) # - Generate профиля парсера на ca-api (один раз; runtime — только правила) diff --git a/AGENTS.md b/AGENTS.md index 2cfbbb9..fcd22ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,19 @@ docker compose up --build cp-workers-web # single worker rebuild Health: `curl http://localhost:8080/api/health` +Admin auth (map is public; `/admin/*` needs JWT from `ADMIN_USER` / `ADMIN_PASSWORD`): + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/admin/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"change-me"}' | jq -r .access_token) +``` + Create Telegram parser: ```bash curl -X POST http://localhost:8080/admin/jobs \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"source_type":"telegram","source_config":{"channel":"example","limit":50}}' ``` diff --git a/README.md b/README.md index e2c3cf1..036c7c8 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,22 @@ cp ../SocialParser/data/telegram.session data/ docker compose up --build ``` -4. Откройте UI: [http://localhost:8080](http://localhost:8080) +4. Откройте UI: [http://localhost:8080](http://localhost:8080) — карта публичная; разделы админки — после входа (`ADMIN_USER` / `ADMIN_PASSWORD` из `.env`). ## Admin UI -Веб-интерфейс ЦА доступен по тем же адресу. Навигация в шапке: +Веб-интерфейс ЦА доступен по тому же адресу. Карта (`/`) открыта всем. Остальные пункты навигации видны после логина (`/login`). | Раздел | Путь | Описание | |--------|------|----------| -| **Карта** | `/` | Интерактивная карта событий: навигация по датам (flatpickr), пресеты периода, фильтры региона/темы/источника, подложки Яндекс/OSM/Topo/ESRI, линейка, полноэкранный режим, центрирование по координатам и городам, поиск населённых пунктов (Nominatim). CRUD для ручных объектов (ПКМ). Поддерживает `?eventId=` | +| **Карта** | `/` | Интерактивная карта событий (публичный просмотр). CRUD ручных объектов — только для админа (ПКМ). Поддерживает `?eventId=` | | **Парсеры** | `/parsers` | Адаптеры `telegram` / `crawl4ai` / `viina`, интервал, CRUD; дедуп по `source_url` | | **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами | | **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов | | **ПИ** | `/consumers` | CRUD подписчиков distribution API, ротация ключей, тест среза через `/api/v1/events` | +Авторизация админки: `POST /admin/auth/login` → JWT Bearer. Все `/admin/*` (кроме login) и мутации `/api/objects` требуют токен. `GET /api/map/*` публичен. + ## Сохранение Telegram-сессии **Важно:** существующий файл сессии **не удаляется и не пересоздаётся**. @@ -132,10 +134,15 @@ docker compose up --build | PATCH | `/admin/consumers/{id}` | Обновить подписчика | | POST | `/admin/consumers/{id}/rotate-key` | Сменить API-ключ | -Пример задания Telegram: +Пример задания Telegram (нужен JWT после `POST /admin/auth/login`): ```bash +TOKEN=$(curl -s -X POST http://localhost:8080/admin/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"change-me"}' | jq -r .access_token) + curl -X POST http://localhost:8080/admin/jobs \ + -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"source_type":"telegram","source_config":{"channel":"creamy_caprice","limit":50}}' ``` diff --git a/centers/analytics/api/app/auth.py b/centers/analytics/api/app/auth.py new file mode 100644 index 0000000..3cfa2d9 --- /dev/null +++ b/centers/analytics/api/app/auth.py @@ -0,0 +1,74 @@ +"""Admin auth: single env-based user + JWT bearer tokens.""" + +from __future__ import annotations + +import hmac +import os +import time +from typing import Any + +import jwt +from fastapi import HTTPException + +ALGORITHM = "HS256" +DEFAULT_TTL_SECONDS = 60 * 60 * 24 # 24h + + +def _admin_user() -> str: + return os.getenv("ADMIN_USER", "admin").strip() or "admin" + + +def _admin_password() -> str: + return os.getenv("ADMIN_PASSWORD", "").strip() + + +def _jwt_secret() -> str: + secret = os.getenv("ADMIN_JWT_SECRET", "").strip() + if not secret: + # Dev fallback: derive from password so local stacks boot without extra secret. + password = _admin_password() + if not password: + raise HTTPException( + status_code=503, + detail="ADMIN_PASSWORD is not configured", + ) + return f"mapmil-dev:{password}" + return secret + + +def admin_credentials_configured() -> bool: + return bool(_admin_password()) + + +def verify_credentials(username: str, password: str) -> bool: + expected_user = _admin_user() + expected_password = _admin_password() + if not expected_password: + return False + user_ok = hmac.compare_digest(username.strip(), expected_user) + pass_ok = hmac.compare_digest(password, expected_password) + return user_ok and pass_ok + + +def create_access_token(*, username: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> str: + now = int(time.time()) + payload: dict[str, Any] = { + "sub": username, + "role": "admin", + "iat": now, + "exp": now + ttl_seconds, + } + return jwt.encode(payload, _jwt_secret(), algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict[str, Any]: + try: + payload = jwt.decode(token, _jwt_secret(), algorithms=[ALGORITHM]) + except jwt.ExpiredSignatureError as exc: + raise HTTPException(status_code=401, detail="Token expired") from exc + except jwt.InvalidTokenError as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc + + if payload.get("role") != "admin" or not payload.get("sub"): + raise HTTPException(status_code=401, detail="Invalid token") + return payload diff --git a/centers/analytics/api/app/deps.py b/centers/analytics/api/app/deps.py index 1a68038..c91bca6 100644 --- a/centers/analytics/api/app/deps.py +++ b/centers/analytics/api/app/deps.py @@ -2,6 +2,8 @@ import os from fastapi import Header, HTTPException +from .auth import decode_access_token + def verify_internal_token( x_internal_token: str | None = Header(default=None, alias="X-Internal-Token"), @@ -9,3 +11,16 @@ def verify_internal_token( expected = os.getenv("INTERNAL_TOKEN", "dev-internal-token") if not x_internal_token or x_internal_token != expected: raise HTTPException(status_code=401, detail="Invalid internal token") + + +def verify_admin( + authorization: str | None = Header(default=None), +) -> str: + """Require Authorization: Bearer . Returns username (sub).""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + token = authorization.removeprefix("Bearer ").strip() + if not token: + raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") + payload = decode_access_token(token) + return str(payload["sub"]) diff --git a/centers/analytics/api/app/main.py b/centers/analytics/api/app/main.py index 93e03d3..b466d49 100644 --- a/centers/analytics/api/app/main.py +++ b/centers/analytics/api/app/main.py @@ -13,7 +13,7 @@ for _candidate in (_HERE.parent, *_HERE.parents): break from .database import Base, engine, get_db -from .routers import admin, internal, map, objects, parse_channels, parser_profiles, v1 +from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1 from .seed import seed_objects, seed_test_consumer from .services.migrations import migrate_schema from .services.scheduler import start_scheduler @@ -49,6 +49,7 @@ app.add_middleware( app.include_router(objects.router) app.include_router(map.router) app.include_router(internal.router) +app.include_router(auth.router) app.include_router(admin.router) app.include_router(parser_profiles.router) app.include_router(parse_channels.router) diff --git a/centers/analytics/api/app/routers/admin.py b/centers/analytics/api/app/routers/admin.py index c416999..c88c320 100644 --- a/centers/analytics/api/app/routers/admin.py +++ b/centers/analytics/api/app/routers/admin.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from ..database import get_db +from ..deps import verify_admin from ..models import Consumer, Event, MapObject, ParseChannel, ParseJob, ParserProfile from ..schemas import ( AnalyticsSummary, @@ -37,7 +38,7 @@ from ..services.filtering import ( ) from ..services.jobs import enqueue_parse_job, flatten_pair_config -router = APIRouter(prefix="/admin", tags=["admin"]) +router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(verify_admin)]) def _validated_source_config(source_type: str, source_config: dict) -> dict: diff --git a/centers/analytics/api/app/routers/auth.py b/centers/analytics/api/app/routers/auth.py new file mode 100644 index 0000000..e2b2eb4 --- /dev/null +++ b/centers/analytics/api/app/routers/auth.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from ..auth import ( + admin_credentials_configured, + create_access_token, + verify_credentials, +) +from ..deps import verify_admin + +router = APIRouter(prefix="/admin/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1) + password: str = Field(min_length=1) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class MeResponse(BaseModel): + username: str + role: str = "admin" + + +@router.post("/login", response_model=TokenResponse) +def login(payload: LoginRequest): + if not admin_credentials_configured(): + raise HTTPException(status_code=503, detail="Admin auth is not configured") + if not verify_credentials(payload.username, payload.password): + raise HTTPException(status_code=401, detail="Invalid username or password") + token = create_access_token(username=payload.username.strip()) + return TokenResponse(access_token=token) + + +@router.get("/me", response_model=MeResponse) +def me(username: str = Depends(verify_admin)): + return MeResponse(username=username) diff --git a/centers/analytics/api/app/routers/objects.py b/centers/analytics/api/app/routers/objects.py index c201c74..0e78fe4 100644 --- a/centers/analytics/api/app/routers/objects.py +++ b/centers/analytics/api/app/routers/objects.py @@ -3,6 +3,7 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session from ..database import get_db +from ..deps import verify_admin from ..models import MapObject, ObjectMedia from ..schemas import MapObjectCreate, MapObjectRead, MapObjectUpdate, ObjectMediaRead from ..storage import ( @@ -48,7 +49,11 @@ def get_object(object_id: int, db: Session = Depends(get_db)): @router.post("/api/objects", response_model=MapObjectRead, status_code=201) -def create_object(payload: MapObjectCreate, db: Session = Depends(get_db)): +def create_object( + payload: MapObjectCreate, + db: Session = Depends(get_db), + _: str = Depends(verify_admin), +): data = payload.model_dump() created_at = data.pop("created_at", None) obj = MapObject(**data) @@ -65,6 +70,7 @@ def update_object( object_id: int, payload: MapObjectUpdate, db: Session = Depends(get_db), + _: str = Depends(verify_admin), ): obj = db.query(MapObject).filter(MapObject.id == object_id).first() if not obj: @@ -79,7 +85,11 @@ def update_object( @router.delete("/api/objects/{object_id}", status_code=204) -def delete_object(object_id: int, db: Session = Depends(get_db)): +def delete_object( + object_id: int, + db: Session = Depends(get_db), + _: str = Depends(verify_admin), +): obj = db.query(MapObject).filter(MapObject.id == object_id).first() if not obj: raise HTTPException(status_code=404, detail="Объект не найден") @@ -111,6 +121,7 @@ async def upload_object_media( object_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), + _: str = Depends(verify_admin), ): obj = db.query(MapObject).filter(MapObject.id == object_id).first() if not obj: @@ -159,7 +170,11 @@ def get_media_file(media_id: int, db: Session = Depends(get_db)): @router.delete("/api/media/{media_id}", status_code=204) -def delete_media(media_id: int, db: Session = Depends(get_db)): +def delete_media( + media_id: int, + db: Session = Depends(get_db), + _: str = Depends(verify_admin), +): media = db.query(ObjectMedia).filter(ObjectMedia.id == media_id).first() if not media: raise HTTPException(status_code=404, detail="Медиафайл не найден") diff --git a/centers/analytics/api/app/routers/parse_channels.py b/centers/analytics/api/app/routers/parse_channels.py index d13c1e3..d1575ef 100644 --- a/centers/analytics/api/app/routers/parse_channels.py +++ b/centers/analytics/api/app/routers/parse_channels.py @@ -6,10 +6,15 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from ..database import get_db +from ..deps import verify_admin from ..models import ParseChannel, ParseJob from ..schemas import ParseChannelCreate, ParseChannelRead, ParseChannelUpdate -router = APIRouter(prefix="/admin/parse-channels", tags=["parse-channels"]) +router = APIRouter( + prefix="/admin/parse-channels", + tags=["parse-channels"], + dependencies=[Depends(verify_admin)], +) ALLOWED_SOURCE_TYPES = {"telegram"} diff --git a/centers/analytics/api/app/routers/parser_profiles.py b/centers/analytics/api/app/routers/parser_profiles.py index fda3f15..bb075aa 100644 --- a/centers/analytics/api/app/routers/parser_profiles.py +++ b/centers/analytics/api/app/routers/parser_profiles.py @@ -11,11 +11,16 @@ from sqlalchemy.orm import Session from contracts.heuristic_profile import HeuristicProfile from ..database import get_db +from ..deps import verify_admin from ..models import ParseJob, ParserProfile from ..schemas import ParserProfileCreate, ParserProfileRead, ParserProfileUpdate from ..services import parser_builder as builder -router = APIRouter(prefix="/admin/parser-profiles", tags=["parser-profiles"]) +router = APIRouter( + prefix="/admin/parser-profiles", + tags=["parser-profiles"], + dependencies=[Depends(verify_admin)], +) class GenerateRequest(BaseModel): diff --git a/centers/analytics/api/requirements.txt b/centers/analytics/api/requirements.txt index fb6c4c0..f1703d2 100644 --- a/centers/analytics/api/requirements.txt +++ b/centers/analytics/api/requirements.txt @@ -6,3 +6,4 @@ python-multipart==0.0.20 psycopg2-binary==2.9.10 redis==5.2.1 httpx==0.28.1 +PyJWT==2.10.1 diff --git a/centers/analytics/frontend/src/api/client.ts b/centers/analytics/frontend/src/api/client.ts index 8eb058f..b416fa1 100644 --- a/centers/analytics/frontend/src/api/client.ts +++ b/centers/analytics/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { clearToken, getToken } from "../auth"; + const API_BASE = "/api"; const ADMIN_BASE = "/admin"; @@ -13,11 +15,24 @@ export async function request( headers.set("Content-Type", "application/json"); } + const token = getToken(); + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + const response = await fetch(`${base}${url}`, { ...options, headers, }); + if (response.status === 401 && token) { + clearToken(); + if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) { + const next = `${window.location.pathname}${window.location.search}`; + window.location.assign(`/login?next=${encodeURIComponent(next)}`); + } + } + if (!response.ok) { const message = await response.text(); throw new Error(message || `Ошибка запроса: ${response.status}`); diff --git a/centers/analytics/frontend/src/auth.ts b/centers/analytics/frontend/src/auth.ts new file mode 100644 index 0000000..f4a1ffe --- /dev/null +++ b/centers/analytics/frontend/src/auth.ts @@ -0,0 +1,40 @@ +import { computed, ref } from "vue"; + +const TOKEN_KEY = "mapmil_admin_token"; +const ADMIN_BASE = "/admin"; + +const token = ref(localStorage.getItem(TOKEN_KEY)); + +export function getToken(): string | null { + return token.value; +} + +export function setToken(value: string | null): void { + token.value = value; + if (value) localStorage.setItem(TOKEN_KEY, value); + else localStorage.removeItem(TOKEN_KEY); +} + +export function clearToken(): void { + setToken(null); +} + +export const isAuthenticated = computed(() => Boolean(token.value)); + +export async function login(username: string, password: string): Promise { + const response = await fetch(`${ADMIN_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Ошибка входа: ${response.status}`); + } + const data = (await response.json()) as { access_token: string }; + setToken(data.access_token); +} + +export function logout(): void { + clearToken(); +} diff --git a/centers/analytics/frontend/src/components/MapView.vue b/centers/analytics/frontend/src/components/MapView.vue index ef0dc01..d4c1a47 100644 --- a/centers/analytics/frontend/src/components/MapView.vue +++ b/centers/analytics/frontend/src/components/MapView.vue @@ -4,11 +4,15 @@ import { onMounted, onUnmounted, watch } from "vue"; import { useLeafletMap } from "../composables/useLeafletMap"; import type { MapObjectWithEvent } from "../types/map"; -const props = defineProps<{ - objects: MapObjectWithEvent[]; - selectedId: number | null; - openPopupId: number | null; -}>(); +const props = withDefaults( + defineProps<{ + objects: MapObjectWithEvent[]; + selectedId: number | null; + openPopupId: number | null; + editable?: boolean; + }>(), + { editable: false }, +); const emit = defineEmits<{ select: [object: MapObjectWithEvent]; @@ -53,7 +57,7 @@ function buildPopupHtml(obj: MapObjectWithEvent): string { } function isDraggable(obj: MapObjectWithEvent): boolean { - return obj.id === props.selectedId && !obj.event_id; + return props.editable && obj.id === props.selectedId && !obj.event_id; } function syncMarkers() { @@ -170,7 +174,7 @@ onUnmounted(() => { }); watch( - () => [props.objects, props.selectedId] as const, + () => [props.objects, props.selectedId, props.editable] as const, () => { syncMarkers(); panToSelected(false); diff --git a/centers/analytics/frontend/src/components/ObjectPanel.vue b/centers/analytics/frontend/src/components/ObjectPanel.vue index 1e378e5..b3827b5 100644 --- a/centers/analytics/frontend/src/components/ObjectPanel.vue +++ b/centers/analytics/frontend/src/components/ObjectPanel.vue @@ -9,9 +9,13 @@ import { import { MEDIA_ACCEPT, OBJECT_TYPE_LABELS } from "../types/object"; import type { MapObjectWithEvent } from "../types/map"; -const props = defineProps<{ - object: MapObjectWithEvent | null; -}>(); +const props = withDefaults( + defineProps<{ + object: MapObjectWithEvent | null; + canEdit?: boolean; + }>(), + { canEdit: false }, +); const emit = defineEmits<{ openEvents: []; @@ -151,7 +155,7 @@ watch(() => props.object?.id, () => loadMedia(), { immediate: true });

Медиа

-
- +
diff --git a/centers/analytics/frontend/src/composables/useAuth.ts b/centers/analytics/frontend/src/composables/useAuth.ts new file mode 100644 index 0000000..1d80db2 --- /dev/null +++ b/centers/analytics/frontend/src/composables/useAuth.ts @@ -0,0 +1,41 @@ +import { computed, ref } from "vue"; +import { useRouter } from "vue-router"; + +import { clearToken, getToken, isAuthenticated, login as doLogin, logout as doLogout } from "../auth"; + +export function useAuth() { + const router = useRouter(); + const busy = ref(false); + const error = ref(""); + + const authenticated = computed(() => isAuthenticated.value); + + async function login(username: string, password: string): Promise { + busy.value = true; + error.value = ""; + try { + await doLogin(username, password); + return true; + } catch (err) { + clearToken(); + error.value = err instanceof Error ? err.message : "Не удалось войти"; + return false; + } finally { + busy.value = false; + } + } + + function logout(): void { + doLogout(); + void router.push({ name: "map" }); + } + + return { + authenticated, + busy, + error, + getToken, + login, + logout, + }; +} diff --git a/centers/analytics/frontend/src/layouts/AppLayout.vue b/centers/analytics/frontend/src/layouts/AppLayout.vue index c882c06..6763a16 100644 --- a/centers/analytics/frontend/src/layouts/AppLayout.vue +++ b/centers/analytics/frontend/src/layouts/AppLayout.vue @@ -2,10 +2,14 @@ import { computed } from "vue"; import { useRoute } from "vue-router"; -const route = useRoute(); +import { useAuth } from "../composables/useAuth"; -const navItems = [ - { to: "/", label: "Карта", exact: true }, +const route = useRoute(); +const { authenticated, logout } = useAuth(); + +const publicNavItems = [{ to: "/", label: "Карта", exact: true }]; + +const adminNavItems = [ { to: "/channels", label: "Каналы" }, { to: "/parser-profiles", label: "Профили" }, { to: "/parsers", label: "Парсеры" }, @@ -14,13 +18,17 @@ const navItems = [ { to: "/consumers", label: "ПИ" }, ]; +const navItems = computed(() => + authenticated.value ? [...publicNavItems, ...adminNavItems] : publicNavItems, +); + function isActive(path: string, exact = false): boolean { if (exact) return route.path === path; return route.path.startsWith(path); } const pageTitle = computed(() => { - const item = navItems.find((n) => isActive(n.to, n.exact)); + const item = navItems.value.find((n) => isActive(n.to, n.exact)); return item?.label ?? "MapMil"; }); @@ -42,6 +50,12 @@ const pageTitle = computed(() => { > {{ item.label }} + + Вход + +
@@ -87,6 +101,7 @@ const pageTitle = computed(() => { .nav { display: flex; + align-items: center; gap: 0.25rem; } @@ -109,6 +124,19 @@ const pageTitle = computed(() => { font-weight: 500; } +.nav-auth { + margin-left: 0.5rem; + color: #1565c0; + font-weight: 500; +} + +.nav-button { + border: none; + background: transparent; + cursor: pointer; + font: inherit; +} + .admin-main { flex: 1; min-height: 0; diff --git a/centers/analytics/frontend/src/router/index.ts b/centers/analytics/frontend/src/router/index.ts index 917f998..4327460 100644 --- a/centers/analytics/frontend/src/router/index.ts +++ b/centers/analytics/frontend/src/router/index.ts @@ -1,10 +1,12 @@ import { createRouter, createWebHistory } from "vue-router"; +import { getToken } from "../auth"; import AppLayout from "../layouts/AppLayout.vue"; import AnalyticsView from "../views/AnalyticsView.vue"; import ChannelsView from "../views/ChannelsView.vue"; import ConsumersView from "../views/ConsumersView.vue"; import EventsView from "../views/EventsView.vue"; +import LoginView from "../views/LoginView.vue"; import MapViewPage from "../views/MapViewPage.vue"; import ParserProfilesView from "../views/ParserProfilesView.vue"; import ParsersView from "../views/ParsersView.vue"; @@ -12,20 +14,65 @@ import ParsersView from "../views/ParsersView.vue"; const router = createRouter({ history: createWebHistory(), routes: [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { public: true }, + }, { path: "/", component: AppLayout, children: [ - { path: "", name: "map", component: MapViewPage }, - { path: "channels", name: "channels", component: ChannelsView }, - { path: "parser-profiles", name: "parser-profiles", component: ParserProfilesView }, - { path: "parsers", name: "parsers", component: ParsersView }, - { path: "events", name: "events", component: EventsView }, - { path: "analytics", name: "analytics", component: AnalyticsView }, - { path: "consumers", name: "consumers", component: ConsumersView }, + { path: "", name: "map", component: MapViewPage, meta: { public: true } }, + { + path: "channels", + name: "channels", + component: ChannelsView, + meta: { requiresAuth: true }, + }, + { + path: "parser-profiles", + name: "parser-profiles", + component: ParserProfilesView, + meta: { requiresAuth: true }, + }, + { + path: "parsers", + name: "parsers", + component: ParsersView, + meta: { requiresAuth: true }, + }, + { + path: "events", + name: "events", + component: EventsView, + meta: { requiresAuth: true }, + }, + { + path: "analytics", + name: "analytics", + component: AnalyticsView, + meta: { requiresAuth: true }, + }, + { + path: "consumers", + name: "consumers", + component: ConsumersView, + meta: { requiresAuth: true }, + }, ], }, ], }); +router.beforeEach((to) => { + if (!to.meta.requiresAuth) return true; + if (getToken()) return true; + return { + name: "login", + query: { next: to.fullPath }, + }; +}); + export default router; diff --git a/centers/analytics/frontend/src/views/LoginView.vue b/centers/analytics/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..cf1effc --- /dev/null +++ b/centers/analytics/frontend/src/views/LoginView.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/centers/analytics/frontend/src/views/MapViewPage.vue b/centers/analytics/frontend/src/views/MapViewPage.vue index 251d71f..c88e871 100644 --- a/centers/analytics/frontend/src/views/MapViewPage.vue +++ b/centers/analytics/frontend/src/views/MapViewPage.vue @@ -16,12 +16,14 @@ import CoordsTools from "../components/map/CoordsTools.vue"; import MapToolbar from "../components/map/MapToolbar.vue"; import PlaceSearch from "../components/map/PlaceSearch.vue"; import ObjectPanel from "../components/ObjectPanel.vue"; +import { useAuth } from "../composables/useAuth"; import type { LeafletMapApi } from "../composables/useLeafletMap"; import type { DateRangePreset, MapFilters, MapObjectWithEvent, MapQueryParams } from "../types/map"; import type { MapObjectCreate, ObjectType } from "../types/object"; const route = useRoute(); const router = useRouter(); +const { authenticated } = useAuth(); const objects = ref([]); const mapFilters = ref(null); @@ -162,6 +164,7 @@ function handleMapContextMenu(payload: { longitude: number; object: MapObjectWithEvent | null; }) { + if (!authenticated.value) return; contextMenu.value = { visible: true, x: payload.x, @@ -257,6 +260,7 @@ async function handleMoveObject(payload: { latitude: number; longitude: number; }) { + if (!authenticated.value) return; if (payload.object.event_id) return; try { await updateObject(payload.object.id, { @@ -327,17 +331,22 @@ onUnmounted(() => { :objects="objects" :selected-id="selectedId" :open-popup-id="openPopupId" + :editable="authenticated" @ready="onMapReady" @center-change="onCenterChange" @select="handleSelectObject" @contextmenu="handleMapContextMenu" @move="handleMoveObject" /> - + { /> { /> (null); const pairForm = ref({ channel_id: null as number | null, profile_id: null as number | null, - limit: 100, + limit: 10, interval_seconds: 3600, }); const editForm = ref({ channel_id: null as number | null, profile_id: null as number | null, - limit: 100, + limit: 10, interval_seconds: 3600, is_active: true, }); @@ -62,7 +62,7 @@ function isPairJob(job: ParseJob | null): boolean { function limitFromConfig(config: Record): number { const limit = config.limit; - return typeof limit === "number" ? limit : 100; + return typeof limit === "number" ? limit : 10; } function sourceSummary(job: ParseJob): string { diff --git a/docker-compose.yml b/docker-compose.yml index 9b4f336..4089416 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,9 @@ services: REDIS_URL: redis://redis:6379/0 INTERNAL_TOKEN: dev-internal-token TEST_PI_API_KEY: test-pi-api-key-change-me + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me} + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-change-me-jwt-secret} volumes: - ca_uploads:/data depends_on: