Compare commits

30 Commits

Author SHA1 Message Date
Logan c6684ea61b Update deploy with next vars
Build & Deploy / Build & push images (push) Successful in 4m9s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
2026-06-22 02:45:49 -04:00
logan fa5f91c0fa Merge pull request 'Infrastructure builds' (#1) from build-infrastructure into main
Build & Deploy / Build & push images (push) Failing after 6m3s
Build & Deploy / Deploy to VM (push) Has been skipped
Reviewed-on: #1
2026-06-22 02:34:58 -04:00
Logan 57ff9f8ea3 Merge remote-tracking branch 'origin/main' into build-infrastructure 2026-06-22 02:34:26 -04:00
Logan 9fdcad1c46 deploy via Gitea CI registry; provision GCP infra with Terraform
- Terraform: e2-micro VM (us-east1-b, free tier), static IP, SSH/web
  firewall rules, IAM bindings for Firestore + GCS; imports existing
  drb-calls bucket and c2-server Firestore database into state
- Gitea CI: build c2-core, discord-bot, frontend images and push to
  git.vpn.cusano.net registry; SSH deploy pulls pre-built images (no
  build on VM)
- Ansible: first-time setup only — git clone, env files from vault,
  Caddyfile, docker login + compose pull + up; no rsync or on-VM builds
- docker-compose: add image: ${REGISTRY}/name:latest alongside build:
  so local dev and CI registry both work
- gitignore: add Terraform state, lock, tfvars, ansible secrets
2026-06-22 02:31:28 -04:00
Logan 33700448bf add Terraform + Ansible infrastructure for GCP deployment
Provisions e2-micro VM (us-east1-b, free tier) with static IP, SSH and
web firewall rules, Docker + Caddy startup script, and IAM bindings for
Firestore and GCS access via ADC. Imports existing drb-calls bucket and
c2-server Firestore database into state. Ansible roles handle first-time
setup (swap, docker group) and all subsequent deploys via rsync + docker
compose, with secrets managed via Ansible Vault. DNS stays on AWS Route 53.
2026-06-22 02:03:36 -04:00
Logan 3defdf18dc stale calls fix 2026-06-22 00:06:10 -04:00
Logan 1f17b6c0d2 feat: add role-based user management, audit log, and session tracking
Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
2026-06-22 00:02:09 -04:00
Logan 961cc6f36e add button to clear stale 'active' calls 2026-06-21 23:45:28 -04:00
Logan d290b89736 New /profile page
Avatar (initials) + display name, email, admin badge
Account section: email, UID, role, join date, last sign-in
Discord section: link status with username/user ID/linked date, or the get-code flow if unlinked, plus unlink button
Sign out button at the bottom
2026-06-21 23:31:10 -04:00
Logan 758c6f4115 discord link banner 2026-06-21 23:23:36 -04:00
Logan 6ae4d398f8 add trips permissions 2026-06-21 20:00:48 -04:00
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 47430827d4 Fix discord trip itinerary 2026-06-21 15:47:07 -04:00
Logan 4dd3343026 add event editing 2026-06-21 15:35:57 -04:00
Logan fce189d8c9 assistant updates 2026-06-21 15:11:30 -04:00
Logan 3fb3bca034 add tags
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
2026-06-21 15:00:37 -04:00
Logan a0fdf2486e chat fixes
Focus: textarea gets refocused via inputRef after the AI response (or error) lands
Persistence: chat history saved to localStorage keyed by trip ID, loaded on mount — survives refreshes
2026-06-21 14:55:34 -04:00
Logan e7622c7e6d chat box fixes 2026-06-21 14:47:17 -04:00
Logan 21d15d0426 assistant markdown update 2026-06-21 14:38:53 -04:00
Logan 21268ab477 fix: migrate Places and Routes to new GCP APIs
Switch from legacy Places textsearch and Directions APIs (disabled on
this project) to Places API (New) and Routes API (New). Both places.py
and the assistant's _places_search helper updated. Also fixes uid()
recursive self-call in trips page and adds Places API response logging.
2026-06-21 14:35:12 -04:00
Logan 522748f07a debugging for trips assistant 2026-06-21 14:31:26 -04:00
Logan af4079d648 fix build 2026-06-21 14:15:09 -04:00
Logan 39c002d090 Fix assistant 2026-06-21 14:08:33 -04:00
Logan 4295bdf4d2 Merge remote-tracking branch 'origin/main' into build-infrastructure 2026-06-21 13:51:58 -04:00
Logan 18d96193ab Security fixes
auth.py

secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py

send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py

add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py

All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py

POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py

join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py

Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py

_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py

Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
2026-06-21 13:40:08 -04:00
Logan a1c91c5ed3 Initial infra attempt 2026-06-21 13:37:03 -04:00
Logan f0a0ea508a adjust assistant height 2026-06-21 13:19:45 -04:00
Logan d64259bb18 Fix auth 2026-06-21 10:14:52 -04:00
Logan 7b9aefbcc5 Add UI to trips 2026-06-21 10:12:33 -04:00
Logan 8edb717dd2 Add trips to UI
lib/types.ts — TripRecord and TripEvent types

lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent

lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date

app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link

app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.

components/Nav.tsx — Trips link added to the nav
2026-06-20 23:34:45 -04:00
58 changed files with 5205 additions and 170 deletions
+98
View File
@@ -0,0 +1,98 @@
name: Build & Deploy
on:
push:
branches: [main]
env:
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
REGISTRY: ${{ secrets.REGISTRY }}
jobs:
build:
name: Build & push images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.BUILD_TOKEN }}
- name: Build & push c2-core
uses: docker/build-push-action@v5
with:
context: ./drb-c2-core
push: true
tags: |
${{ env.REGISTRY }}/c2-core:latest
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
- name: Build & push discord-bot
uses: docker/build-push-action@v5
with:
context: ./drb-server-discord-bot
push: true
tags: |
${{ env.REGISTRY }}/discord-bot:latest
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
- name: Build & push frontend
uses: docker/build-push-action@v5
with:
context: ./drb-frontend
push: true
tags: |
${{ env.REGISTRY }}/frontend:latest
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
build-args: |
NEXT_PUBLIC_C2_URL=https://api.${{ secrets.DRB_DOMAIN }}
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
deploy:
name: Deploy to VM
needs: build
runs-on: ubuntu-latest
steps:
- name: Write SSH key
run: |
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
- name: Deploy
run: |
ssh -o StrictHostKeyChecking=no \
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
-i /tmp/deploy_key \
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
set -e
cd /opt/drb
# Update compose files + mosquitto config
git pull origin main
# Pull pre-built images and restart (no build on the VM)
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
ENDSSH
- name: Health check
run: |
sleep 20
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
(echo "Health check failed" && exit 1)
+12
View File
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
drb-frontend/.env drb-frontend/.env
drb-c2-core/gcp-key.json drb-c2-core/gcp-key.json
# Terraform
infra/.terraform/
infra/terraform.tfstate
infra/terraform.tfstate.backup
infra/terraform.tfstate.*.backup
infra/.terraform.lock.hcl
infra/terraform.tfvars
infra/tf.log
infra/ansible/inventory.ini
infra/ansible/group_vars/all.yml
infra/ansible/vault.yml
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+26
View File
@@ -0,0 +1,26 @@
# Production overrides — used on the VM.
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Differences from dev:
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
# - restart: always (instead of unless-stopped) for hard reboots.
services:
mosquitto:
restart: always
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
c2-core:
restart: always
ports:
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot:
restart: always
frontend:
restart: always
ports:
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
+3 -2
View File
@@ -17,17 +17,17 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
c2-core: c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest}
build: ./drb-c2-core build: ./drb-c2-core
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8888:8000" - "8888:8000"
env_file: ./drb-c2-core/.env env_file: ./drb-c2-core/.env
volumes:
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
depends_on: depends_on:
- mosquitto - mosquitto
discord-bot: discord-bot:
image: ${REGISTRY}/discord-bot:${TAG:-latest}
build: ./drb-server-discord-bot build: ./drb-server-discord-bot
restart: unless-stopped restart: unless-stopped
env_file: ./drb-server-discord-bot/.env env_file: ./drb-server-discord-bot/.env
@@ -35,6 +35,7 @@ services:
- c2-core - c2-core
frontend: frontend:
image: ${REGISTRY}/frontend:${TAG:-latest}
build: ./drb-frontend build: ./drb-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
+5 -1
View File
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase # Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None service_key: Optional[str] = None
# CORS — comma-separated list of allowed origins, or "*" for all # Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only.
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
class Config: class Config:
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from app.internal import firestore as fstore
async def write_audit(
actor_uid: str,
actor_email: str,
action: str,
target_uid: Optional[str] = None,
target_email: Optional[str] = None,
details: Optional[dict] = None,
) -> None:
"""Write an entry to the audit_log collection."""
doc_id = str(uuid4())
await fstore.doc_set("audit_log", doc_id, {
"log_id": doc_id,
"action": action,
"actor_uid": actor_uid,
"actor_email": actor_email,
"target_uid": target_uid,
"target_email": target_email,
"details": details or {},
"timestamp": datetime.now(timezone.utc).isoformat(),
}, merge=False)
+91 -3
View File
@@ -1,3 +1,6 @@
import secrets
import time
from collections import defaultdict, deque
from typing import Optional from typing import Optional
from fastapi import HTTPException, Security from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
if not credentials: if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token") raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials token = credentials.credentials
if settings.service_key and token == settings.service_key: if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True} return {"service": True}
try: try:
return firebase_auth.verify_id_token(token) return firebase_auth.verify_id_token(token)
@@ -34,11 +37,96 @@ async def require_service_or_firebase_token(
raise HTTPException(status_code=401, detail="Invalid or expired token") raise HTTPException(status_code=401, detail="Invalid or expired token")
def get_role(decoded: dict) -> str:
"""Extract the effective role from a decoded Firebase token.
Checks the granular ``role`` claim first, then falls back to the legacy
``admin`` boolean so existing tokens continue to work during the transition.
"""
if decoded.get("role") == "admin" or decoded.get("admin"):
return "admin"
role = decoded.get("role", "viewer")
return role if role in ("admin", "operator", "viewer") else "viewer"
async def require_admin_token( async def require_admin_token(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict: ) -> dict:
"""Verify a Firebase ID token AND require the admin custom claim.""" """Verify a Firebase ID token AND require the admin role.
Accepts both the legacy ``admin: True`` boolean claim and the newer
``role: "admin"`` claim so tokens issued before the role migration still work.
"""
decoded = await require_firebase_token(credentials) decoded = await require_firebase_token(credentials)
if not decoded.get("admin"): if get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Admin access required") raise HTTPException(status_code=403, detail="Admin access required")
return decoded return decoded
async def require_service_key(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept only the internal service key — used for bot-only endpoints."""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
if not settings.service_key:
raise HTTPException(status_code=503, detail="Service key not configured")
if not secrets.compare_digest(credentials.credentials, settings.service_key):
raise HTTPException(status_code=403, detail="Service key required")
return {"service": True}
async def require_service_key_or_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept either the internal service key or a Firebase admin token.
Used for endpoints that the Discord bot (service key) and dashboard admins
(Firebase + admin claim) both need to call, but regular Firebase users must not.
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
decoded = firebase_auth.verify_id_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token")
if get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return decoded
# ---------------------------------------------------------------------------
# Simple in-memory sliding-window rate limiter
# ---------------------------------------------------------------------------
# Not persistent across restarts; good enough for a single-instance deployment.
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
class _RateLimiter:
def __init__(self, max_calls: int, window_seconds: int):
self.max_calls = max_calls
self.window = window_seconds
self._log: dict[str, deque] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._log[key]
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.max_calls:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Please wait before trying again.",
)
q.append(now)
# Shared limiter instances
# trip chat: 20 requests per user per 5 minutes
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
# per-incident summarize: 5 per incident per 10 minutes
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# vocabulary bootstrap: 2 per system per hour
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
+17 -2
View File
@@ -5,7 +5,21 @@ from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
async def upload_audio(data: bytes, filename: str) -> Optional[str]: def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id.
We ignore the client-supplied filename entirely and derive the name from the
call_id (which we control) to prevent path traversal via crafted filenames.
The original extension is preserved only if it's a known audio type.
"""
import os
ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3"
return f"{call_id}{ext}"
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
"""Upload audio bytes to GCS and return a signed URL, or None if disabled.""" """Upload audio bytes to GCS and return a signed URL, or None if disabled."""
if not settings.gcs_bucket: if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.") logger.info("GCS_BUCKET not configured — skipping audio upload.")
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
client = storage.Client() client = storage.Client()
signing_creds = None signing_creds = None
bucket = client.bucket(settings.gcs_bucket) bucket = client.bucket(settings.gcs_bucket)
blob = bucket.blob(f"calls/{filename}") safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob.upload_from_string(data, content_type="audio/mpeg") blob.upload_from_string(data, content_type="audio/mpeg")
if signing_creds: if signing_creds:
return blob.generate_signed_url( return blob.generate_signed_url(
+4 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop from app.internal.recorrelation_sweep import recorrelation_loop
from app.config import settings from app.config import settings
from app.internal.auth import require_firebase_token, require_service_or_firebase_token from app.internal.auth import require_firebase_token, require_service_or_firebase_token
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -69,8 +69,11 @@ app.include_router(tokens.router, dependencies=[Depends(require_service_or_fi
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(upload.router) # auth is per-node, handled inline app.include_router(upload.router) # auth is per-node, handled inline
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
app.include_router(users.router) # auth: admin only
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
@app.get("/health") @app.get("/health")
+21 -2
View File
@@ -146,15 +146,34 @@ class TripCreate(BaseModel):
maps_link: Optional[str] = None maps_link: Optional[str] = None
start_date: str # YYYY-MM-DD start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
visibility: str = "public" # "public" | "private"
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
class TripEventCreate(BaseModel): class TripEventCreate(BaseModel):
title: str title: str
date: str # YYYY-MM-DD, must fall within parent trip range date: str # YYYY-MM-DD, must fall within parent trip range
time: Optional[str] = None # HH:MM (24h) start_time: Optional[str] = None # HH:MM (24h)
end_time: Optional[str] = None # HH:MM (24h)
location: Optional[str] = None # inherits trip location if None location: Optional[str] = None # inherits trip location if None
maps_link: Optional[str] = None maps_link: Optional[str] = None
place_id: Optional[str] = None # Google Place ID
notes: Optional[str] = None notes: Optional[str] = None
tags: List[str] = [] # tag labels applied to this event
class TripEventUpdate(BaseModel):
title: Optional[str] = None
date: Optional[str] = None
start_time: Optional[str] = None
end_time: Optional[str] = None
location: Optional[str] = None
maps_link: Optional[str] = None
place_id: Optional[str] = None
notes: Optional[str] = None
tags: Optional[List[str]] = None
class AttendeeAction(BaseModel): class AttendeeAction(BaseModel):
+12 -1
View File
@@ -5,7 +5,6 @@ from app.internal.auth import require_admin_token, require_firebase_token
from app.internal.feature_flags import get_flags, set_flags from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore from app.internal import firestore as fstore
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]: async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
"""Return system_ids where at least one AI function (STT or correlation) is effectively on.""" """Return system_ids where at least one AI function (STT or correlation) is effectively on."""
global_stt = global_flags.get("stt_enabled", True) global_stt = global_flags.get("stt_enabled", True)
@@ -163,3 +162,15 @@ async def debug_correlation(
"incidents": incident_records, "incidents": incident_records,
"orphaned_calls": orphans[:250], "orphaned_calls": orphans[:250],
} }
@router.get("/audit")
async def get_audit_log(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_=Depends(require_admin_token),
):
"""Return paginated audit log entries, most recent first."""
entries = await fstore.collection_list("audit_log")
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
return entries[offset: offset + limit]
+45
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@@ -59,6 +60,50 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
return {"ok": True, "call_id": call_id} return {"ok": True, "call_id": call_id}
@router.post("/close-stale")
async def close_stale_calls(
older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."),
dry_run: bool = Query(False, description="If true, return what would be closed without writing."),
_: dict = Depends(require_admin_token),
):
"""
Find and close calls stuck in 'active' status — e.g. because a node rebooted
before sending an end-call event. Returns the list of affected call IDs.
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
active_calls = await fstore.collection_list("calls", status="active")
stale = []
for call in active_calls:
started_raw = call.get("started_at")
if not started_raw:
continue
if isinstance(started_raw, datetime):
started = started_raw if started_raw.tzinfo else started_raw.replace(tzinfo=timezone.utc)
else:
try:
started = datetime.fromisoformat(str(started_raw).replace("Z", "+00:00"))
except Exception:
continue
if started < cutoff:
stale.append(call)
if not dry_run:
now_iso = datetime.now(timezone.utc).isoformat()
for call in stale:
await fstore.doc_set("calls", call["call_id"], {
"status": "ended",
"ended_at": now_iso,
})
return {
"dry_run": dry_run,
"older_than_minutes": older_than_minutes,
"count": len(stale),
"call_ids": [c["call_id"] for c in stale],
}
@router.patch("/{call_id}/transcript") @router.patch("/{call_id}/transcript")
async def patch_transcript( async def patch_transcript(
call_id: str, call_id: str,
+12 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
router = APIRouter(prefix="/incidents", tags=["incidents"]) router = APIRouter(prefix="/incidents", tags=["incidents"])
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
@router.post("/summarize") @router.post("/summarize")
async def summarize_all_stale(background_tasks: BackgroundTasks): async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval).""" """Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass) background_tasks.add_task(_run_summary_pass)
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
@router.post("/{incident_id}/summarize") @router.post("/{incident_id}/summarize")
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks): async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident.""" """Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id) inc = await fstore.doc_get("incidents", incident_id)
if not inc: if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.") raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc) background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id} return {"ok": True, "incident_id": incident_id}
+152
View File
@@ -0,0 +1,152 @@
import random
import string
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
_CODE_TTL_MINUTES = 15
def _gen_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
# ---------------------------------------------------------------------------
# Web: generate a short-lived linking code
# ---------------------------------------------------------------------------
@router.post("/link/generate")
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
"""Authenticated Firebase user generates a code to paste into Discord /link."""
firebase_uid = decoded["uid"]
# Check if already linked
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
if existing and existing.get("discord_user_id"):
return {
"already_linked": True,
"discord_user_id": existing["discord_user_id"],
}
code = _gen_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
await fstore.doc_set("link_codes", code, {
"firebase_uid": firebase_uid,
"expires_at": expires_at,
}, merge=False)
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
# ---------------------------------------------------------------------------
# Discord bot: resolve a code and store the link
# ---------------------------------------------------------------------------
class LinkResolveBody(BaseModel):
code: str
discord_user_id: str
discord_username: str = ""
@router.post("/link")
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
"""Discord bot resolves a linking code and permanently links the accounts."""
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
if not doc:
raise HTTPException(404, "Invalid or expired code.")
expires_at = datetime.fromisoformat(doc["expires_at"])
if datetime.now(timezone.utc) > expires_at:
await fstore.doc_delete("link_codes", body.code)
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
firebase_uid = doc["firebase_uid"]
# Check if this Discord account is already linked to a different Firebase UID
existing = await fstore.doc_get("discord_links", body.discord_user_id)
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
raise HTTPException(409, "This Discord account is already linked to a different account.")
now = datetime.now(timezone.utc).isoformat()
# Store both directions
await fstore.doc_set("discord_links", body.discord_user_id, {
"firebase_uid": firebase_uid,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
await fstore.doc_set("firebase_discord_links", firebase_uid, {
"discord_user_id": body.discord_user_id,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
# Clean up the code
await fstore.doc_delete("link_codes", body.code)
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
return {"ok": True, "firebase_uid": firebase_uid}
# ---------------------------------------------------------------------------
# Web: check current link status
# ---------------------------------------------------------------------------
@router.get("/link/status")
async def link_status(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if link and link.get("discord_user_id"):
return {
"linked": True,
"discord_user_id": link["discord_user_id"],
"discord_username": link.get("discord_username", ""),
"linked_at": link.get("linked_at"),
}
return {"linked": False}
# ---------------------------------------------------------------------------
# Web: unlink
# ---------------------------------------------------------------------------
@router.delete("/link")
async def unlink(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if not link or not link.get("discord_user_id"):
raise HTTPException(404, "No linked Discord account.")
discord_user_id = link["discord_user_id"]
await fstore.doc_delete("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True}
# ---------------------------------------------------------------------------
# Session recording — called by the frontend on each successful sign-in
# ---------------------------------------------------------------------------
@router.post("/session")
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
"""Record a sign-in event for the authenticated user."""
session_id = str(uuid4())
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
await fstore.doc_set("user_sessions", session_id, {
"session_id": session_id,
"uid": decoded["uid"],
"email": decoded.get("email", ""),
"timestamp": datetime.now(timezone.utc).isoformat(),
"ip": ip,
"user_agent": user_agent,
}, merge=False)
return {"ok": True}
+7 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import CommandPayload from app.models import CommandPayload
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.routers.tokens import assign_token, release_token from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"]) router = APIRouter(prefix="/nodes", tags=["nodes"])
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/command") @router.post("/{node_id}/command")
async def send_command(node_id: str, cmd: CommandPayload): async def send_command(
node_id: str,
cmd: CommandPayload,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id) node = await fstore.doc_get("nodes", node_id)
if not node: if not node:
raise HTTPException(404, f"Node '{node_id}' not found.") raise HTTPException(404, f"Node '{node_id}' not found.")
@@ -108,6 +112,7 @@ async def assign_system(
system_id: str, system_id: str,
hardware_preset: str = Query("rtl-sdr-v3"), hardware_preset: str = Query("rtl-sdr-v3"),
ppm_override: Optional[float] = Query(None), ppm_override: Optional[float] = Query(None),
_: dict = Depends(require_service_key_or_admin),
): ):
""" """
Assign a system to a node. Fetches the system config from Firestore Assign a system to a node. Fetches the system config from Firestore
+100
View File
@@ -0,0 +1,100 @@
import httpx
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.internal.logger import logger
router = APIRouter(prefix="/places", tags=["places"])
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
_ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes"
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri,places.location"
@router.get("/search")
async def search_places(query: str = Query(...), near: str = Query("")):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
full_query = f"{query} {near}".strip()
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
_PLACES_SEARCH_URL,
json={"textQuery": full_query},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": _PLACES_FIELDS,
},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Places search failed: {e}")
raise HTTPException(502, "Places search failed.")
return [
{
"name": p.get("displayName", {}).get("text"),
"address": p.get("formattedAddress"),
"place_id": p.get("id"),
"lat": p.get("location", {}).get("latitude"),
"lng": p.get("location", {}).get("longitude"),
"maps_link": p.get("googleMapsUri"),
"rating": p.get("rating"),
}
for p in data.get("places", [])[:6]
]
@router.get("/directions")
async def get_directions(
origin: str = Query(...),
destination: str = Query(...),
):
if not settings.google_maps_api_key:
raise HTTPException(503, "Google Maps API not configured.")
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
_ROUTES_URL,
json={
"origin": {"address": origin},
"destination": {"address": destination},
"travelMode": "DRIVE",
},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": "routes.duration,routes.distanceMeters",
},
)
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Directions failed: {e}")
raise HTTPException(502, "Directions request failed.")
routes = data.get("routes", [])
if not routes:
return {"duration_text": None, "duration_seconds": None, "distance_text": None}
route = routes[0]
duration_seconds = int(route.get("duration", "0s").rstrip("s") or 0)
distance_m = route.get("distanceMeters", 0)
# Format human-readable strings
hours, rem = divmod(duration_seconds, 3600)
mins = rem // 60
if hours:
duration_text = f"{hours} hr {mins} min" if mins else f"{hours} hr"
else:
duration_text = f"{mins} min"
miles = distance_m / 1609.34
distance_text = f"{miles:.1f} mi"
return {
"duration_text": duration_text,
"duration_seconds": duration_seconds,
"distance_text": distance_text,
}
+40 -11
View File
@@ -1,9 +1,10 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Optional from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
router = APIRouter(prefix="/systems", tags=["systems"]) router = APIRouter(prefix="/systems", tags=["systems"])
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_system(body: SystemCreate): async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
system_id = str(uuid.uuid4()) system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump()) doc = SystemRecord(system_id=system_id, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False) await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
@router.put("/{system_id}") @router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate): async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
@router.delete("/{system_id}", status_code=204) @router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str): async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
# ── Per-system AI flag overrides ────────────────────────────────────────────── # ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags") @router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(system_id: str, body: AiFlagsBody): async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
""" """
Set per-system AI flag overrides. Only fields included in the body are Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global). written; omitted fields remain unchanged (or absent, meaning inherit global).
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
@router.put("/{system_id}/ten-codes") @router.put("/{system_id}/ten-codes")
async def update_ten_codes(system_id: str, body: TenCodesBody): async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system.""" """Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202) @router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(system_id: str): async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge.""" """Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id) terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms} return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms") @router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody): async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary.""" """Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.delete("/{system_id}/vocabulary/terms") @router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody): async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary.""" """Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/approve") @router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(system_id: str, body: VocabularyTermBody): async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary.""" """Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/dismiss") @router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(system_id: str, body: VocabularyTermBody): async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it.""" """Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id) existing = await fstore.doc_get("systems", system_id)
if not existing: if not existing:
+11 -6
View File
@@ -1,9 +1,10 @@
import uuid import uuid
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
router = APIRouter(prefix="/tokens", tags=["tokens"]) router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -22,13 +23,13 @@ async def list_tokens():
"""List all tokens. The actual token string is masked for safety.""" """List all tokens. The actual token string is masked for safety."""
tokens = await fstore.collection_list("bot_tokens") tokens = await fstore.collection_list("bot_tokens")
return [ return [
{**t, "token": t["token"][:10] + "" + t["token"][-4:]} {**t, "token": "•••" + t["token"][-4:]}
for t in tokens for t in tokens
] ]
@router.post("", status_code=201) @router.post("", status_code=201)
async def add_token(body: TokenCreate): async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
token_id = str(uuid.uuid4()) token_id = str(uuid.uuid4())
doc = { doc = {
"token_id": token_id, "token_id": token_id,
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
@router.post("/flush", status_code=200) @router.post("/flush", status_code=200)
async def flush_tokens(): async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned).""" """Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
def _find(): def _find():
from app.internal.firestore import db from app.internal.firestore import db
@@ -61,7 +62,11 @@ async def flush_tokens():
@router.put("/{token_id}/prefer/{system_id}", status_code=200) @router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(token_id: str, system_id: str): async def set_preferred_system(
token_id: str,
system_id: str,
_: dict = Depends(require_admin_token),
):
""" """
Mark this token as the preferred bot for a system. Mark this token as the preferred bot for a system.
When a discord_join is issued for any node in that system, this token When a discord_join is issued for any node in that system, this token
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
@router.delete("/{token_id}", status_code=204) @router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str): async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id) existing = await fstore.doc_get("bot_tokens", token_id)
if not existing: if not existing:
raise HTTPException(404, "Token not found.") raise HTTPException(404, "Token not found.")
+460 -13
View File
@@ -1,16 +1,226 @@
import uuid import uuid
import json
import httpx
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from app.models import TripCreate, TripEventCreate, AttendeeAction from pydantic import BaseModel
from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.config import settings
from app.internal.logger import logger
from app.internal.auth import (
require_service_or_firebase_token,
require_service_key,
require_service_key_or_admin,
trip_chat_limiter,
)
router = APIRouter(prefix="/trips", tags=["trips"]) router = APIRouter(prefix="/trips", tags=["trips"])
# ---------------------------------------------------------------------------
# Access control helpers
# ---------------------------------------------------------------------------
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
return (link or {}).get("discord_user_id")
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
"""Return True if the caller may read this trip."""
if is_service:
return True # bot sees all; it filters client-side per-user
if trip.get("visibility", "public") == "public":
return True
if not firebase_uid:
return False
# attendees keyed by discord_id — check linked discord_id
if discord_id:
if discord_id in trip.get("attendees", {}):
return True
if discord_id in trip.get("invited_discord_ids", []):
return True
return False
# ---------------------------------------------------------------------------
# AI assistant — tool definitions
# ---------------------------------------------------------------------------
_TOOLS = [
{
"type": "function",
"function": {
"name": "search_places",
"description": (
"Search Google Maps for places (restaurants, bars, attractions, hotels, venues). "
"Use this whenever the user asks about specific places or you need to find options."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'",
},
"near": {
"type": "string",
"description": "Location to search near, e.g. 'downtown Nashville, TN'",
},
},
"required": ["query", "near"],
},
},
},
{
"type": "function",
"function": {
"name": "add_tag",
"description": (
"Add a new tag to the trip's available tag list so it can be used on events. "
"Use this when you want to apply a tag that doesn't exist yet."
),
"parameters": {
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'",
},
},
"required": ["tag"],
},
},
},
{
"type": "function",
"function": {
"name": "propose_event",
"description": (
"Propose a specific event to add to the itinerary. "
"The user will see a card and can approve or dismiss it. "
"Call this once per proposed event — do not bundle multiple events into one call."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"},
"start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"},
"end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"},
"location": {"type": "string", "description": "Full address or place name"},
"maps_link": {"type": "string", "description": "Google Maps URL"},
"notes": {"type": "string", "description": "Brief tips or reasoning"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"},
},
"required": ["title"],
},
},
},
]
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri"
async def _places_search(query: str, near: str) -> list[dict]:
if not settings.google_maps_api_key:
return []
full_query = f"{query} {near}".strip()
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.post(
_PLACES_SEARCH_URL,
json={"textQuery": full_query},
headers={
"X-Goog-Api-Key": settings.google_maps_api_key,
"X-Goog-FieldMask": _PLACES_FIELDS,
},
)
data = r.json()
places = data.get("places", [])
logger.info(f"Places search '{full_query}': count={len(places)}")
if not places and "error" in data:
logger.warning(f"Places API error: {data['error'].get('message', '')}")
return [
{
"name": p.get("displayName", {}).get("text"),
"address": p.get("formattedAddress"),
"place_id": p.get("id"),
"maps_link": p.get("googleMapsUri"),
"rating": p.get("rating"),
}
for p in places[:5]
]
except Exception as e:
logger.error(f"Places search in assistant failed: {e}")
return []
def _build_system_prompt(trip: dict, events: list[dict]) -> str:
by_date: dict[str, list] = {}
for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")):
by_date.setdefault(e["date"], []).append(e)
lines = []
for date, day_events in sorted(by_date.items()):
lines.append(f"\n {date}:")
for e in day_events:
t = ""
if e.get("start_time"):
t = f" {e['start_time']}"
if e.get("end_time"):
t += f"{e['end_time']}"
loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else ""
lines.append(f"{e['title']}{t}{loc}")
if e.get("notes"):
lines.append(f" Notes: {e['notes']}")
itinerary = "".join(lines) if lines else "\n (no events yet)"
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
available_tags = trip.get("available_tags") or []
tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else ""
return f"""You are a trip planning assistant for the following trip.
Trip: {trip["name"]}
Destination: {trip["location"]}
Dates: {trip["start_date"]} to {trip["end_date"]}
Attendees: {attendees}{tags_section}
Current itinerary:{itinerary}
Guidelines:
- Be conversational and concise — don't over-explain.
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
- When proposing events, apply relevant tags. Before using a tag, check if it exists in the available tags list. If it doesn't, call `add_tag` first to create it, then use it in `propose_event`.
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
- If you don't know a specific address, search for the place first."""
class ChatMsg(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
message: str
history: list[ChatMsg] = []
@router.get("") @router.get("")
async def list_trips(): async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
return await fstore.collection_list("trips") trips = await fstore.collection_list("trips")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
@router.post("") @router.post("")
@@ -27,6 +237,10 @@ async def create_trip(body: TripCreate):
"start_date": body.start_date, "start_date": body.start_date,
"end_date": body.end_date, "end_date": body.end_date,
"attendees": {}, # {discord_user_id: discord_username} "attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags,
"overlap_tags": body.overlap_tags,
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
"invited_discord_ids": body.invited_discord_ids,
"created_at": now, "created_at": now,
} }
await fstore.doc_set("trips", trip_id, doc, merge=False) await fstore.doc_set("trips", trip_id, doc, merge=False)
@@ -34,17 +248,34 @@ async def create_trip(body: TripCreate):
@router.get("/{trip_id}") @router.get("/{trip_id}")
async def get_trip(trip_id: str): async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
is_service = bool(decoded.get("service"))
firebase_uid = decoded.get("uid")
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
raise HTTPException(403, "This trip is private.")
events = await fstore.collection_list("trip_events", trip_id=trip_id) events = await fstore.collection_list("trip_events", trip_id=trip_id)
events.sort(key=lambda e: (e["date"], e.get("time") or "")) events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
return {**trip, "events": events} return {**trip, "events": events}
@router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict):
"""Replace the trip's available tag list and overlap-allowed tag list."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
tags = [str(t) for t in body.get("available_tags", []) if t]
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
return {"available_tags": tags, "overlap_tags": overlap}
@router.delete("/{trip_id}") @router.delete("/{trip_id}")
async def delete_trip(trip_id: str): async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -56,18 +287,65 @@ async def delete_trip(trip_id: str):
@router.post("/{trip_id}/join") @router.post("/{trip_id}/join")
async def join_trip(trip_id: str, body: AttendeeAction): async def join_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
if trip.get("visibility", "public") == "private":
invited = trip.get("invited_discord_ids", [])
attendees_existing = trip.get("attendees", {})
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
raise HTTPException(403, "This trip is private. You need an invite to join.")
attendees = trip.get("attendees", {}) attendees = trip.get("attendees", {})
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
await fstore.doc_update("trips", trip_id, {"attendees": attendees}) await fstore.doc_update("trips", trip_id, {"attendees": attendees})
return {"ok": True, "attendees": attendees} return {"ok": True, "attendees": attendees}
@router.put("/{trip_id}/visibility")
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
visibility = body.get("visibility", "public")
if visibility not in ("public", "private"):
raise HTTPException(400, "visibility must be 'public' or 'private'.")
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
return {"visibility": visibility}
@router.post("/{trip_id}/invite/{discord_user_id}")
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.delete("/{trip_id}/invite/{discord_user_id}")
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
return {"ok": True, "invited_discord_ids": invited}
@router.post("/{trip_id}/leave") @router.post("/{trip_id}/leave")
async def leave_trip(trip_id: str, body: AttendeeAction): async def leave_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave a trip. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -102,11 +380,14 @@ async def create_event(trip_id: str, body: TripEventCreate):
"trip_id": trip_id, "trip_id": trip_id,
"title": body.title, "title": body.title,
"date": body.date, "date": body.date,
"time": body.time, "start_time": body.start_time,
"end_time": body.end_time,
"location": body.location if body.location is not None else trip["location"], "location": body.location if body.location is not None else trip["location"],
"location_inherited": body.location is None, "location_inherited": body.location is None,
"maps_link": body.maps_link, "maps_link": body.maps_link,
"place_id": body.place_id,
"notes": body.notes, "notes": body.notes,
"tags": body.tags,
"attendees": {}, "attendees": {},
"created_at": now, "created_at": now,
} }
@@ -114,8 +395,51 @@ async def create_event(trip_id: str, body: TripEventCreate):
return doc return doc
@router.patch("/{trip_id}/events/{event_id}")
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
updates: dict = {}
if body.title is not None:
updates["title"] = body.title
if body.date is not None:
if not (trip["start_date"] <= body.date <= trip["end_date"]):
raise HTTPException(400, f"Event date {body.date} is outside the trip range.")
updates["date"] = body.date
if body.start_time is not None:
updates["start_time"] = body.start_time or None
if body.end_time is not None:
updates["end_time"] = body.end_time or None
if body.location is not None:
updates["location"] = body.location
updates["location_inherited"] = False
if body.maps_link is not None:
updates["maps_link"] = body.maps_link or None
if body.place_id is not None:
updates["place_id"] = body.place_id or None
if body.notes is not None:
updates["notes"] = body.notes or None
if body.tags is not None:
updates["tags"] = body.tags
if updates:
await fstore.doc_update("trip_events", event_id, updates)
return {**event, **updates}
@router.delete("/{trip_id}/events/{event_id}") @router.delete("/{trip_id}/events/{event_id}")
async def delete_event(trip_id: str, event_id: str): async def delete_event(
trip_id: str,
event_id: str,
_: dict = Depends(require_service_key_or_admin),
):
event = await fstore.doc_get("trip_events", event_id) event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id: if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -124,7 +448,13 @@ async def delete_event(trip_id: str, event_id: str):
@router.post("/{trip_id}/events/{event_id}/join") @router.post("/{trip_id}/events/{event_id}/join")
async def join_event(trip_id: str, event_id: str, body: AttendeeAction): async def join_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join an event. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -140,7 +470,13 @@ async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
@router.post("/{trip_id}/events/{event_id}/leave") @router.post("/{trip_id}/events/{event_id}/leave")
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction): async def leave_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave an event. Only the Discord bot (service key) may call this."""
event = await fstore.doc_get("trip_events", event_id) event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id: if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.") raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -148,3 +484,114 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
attendees.pop(body.discord_user_id, None) attendees.pop(body.discord_user_id, None)
await fstore.doc_update("trip_events", event_id, {"attendees": attendees}) await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
return {"ok": True} return {"ok": True}
# ---------------------------------------------------------------------------
# AI trip planning assistant
# ---------------------------------------------------------------------------
@router.post("/{trip_id}/chat")
async def trip_chat(
trip_id: str,
body: ChatRequest,
decoded: dict = Depends(require_service_or_firebase_token),
):
if not settings.openai_api_key:
raise HTTPException(503, "OpenAI not configured.")
# Rate limit by caller identity
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
events = await fstore.collection_list("trip_events", trip_id=trip_id)
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
{"role": m.role, "content": m.content}
for m in body.history[-20:]
if m.role in ("user", "assistant")
]
# Truncate message to prevent oversized single requests
user_message = body.message[:2000]
messages: list[dict] = [
{"role": "system", "content": _build_system_prompt(trip, events)},
*safe_history,
{"role": "user", "content": user_message},
]
suggestions: list[dict] = []
reply = ""
for _ in range(6): # max tool-call iterations
response = await oai.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=_TOOLS,
tool_choice="auto",
max_tokens=1000,
)
msg = response.choices[0].message
if not msg.tool_calls:
reply = msg.content or ""
break
# Append assistant message with tool calls
messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
if tc.function.name == "add_tag":
new_tag = str(args.get("tag", "")).strip()[:50]
if new_tag and new_tag not in trip.get("available_tags", []):
updated_tags = list(trip.get("available_tags") or []) + [new_tag]
trip["available_tags"] = updated_tags
await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags})
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"available_tags": trip.get("available_tags", [])}),
})
elif tc.function.name == "search_places":
# Limit query string lengths before hitting the Maps API
query = str(args.get("query", ""))[:200]
near = str(args.get("near", ""))[:200]
results = await _places_search(query, near)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(results),
})
elif tc.function.name == "propose_event":
suggestion = {k: args.get(k) for k in (
"title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags"
)}
if not isinstance(suggestion.get("tags"), list):
suggestion["tags"] = []
suggestions.append(suggestion)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps({"proposed": True, "title": args.get("title")}),
})
if not reply:
reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip."
return {"reply": reply, "suggestions": suggestions}
+4 -2
View File
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.logger import logger from app.internal.logger import logger
from app.config import settings
router = APIRouter(tags=["upload"]) router = APIRouter(tags=["upload"])
@@ -43,9 +44,10 @@ async def upload_call_audio(
data = await file.read() data = await file.read()
if not data: if not data:
raise HTTPException(400, "Empty file.") raise HTTPException(400, "Empty file.")
if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
filename = file.filename audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
audio_url = await upload_audio(data, filename)
if audio_url: if audio_url:
try: try:
+308
View File
@@ -0,0 +1,308 @@
import asyncio
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from firebase_admin import auth as firebase_auth
from app.internal.auth import require_admin_token
from app.internal import firestore as fstore
from app.internal import audit
router = APIRouter(prefix="/admin/users", tags=["users"])
VALID_ROLES = {"admin", "operator", "viewer"}
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class UserCreate(BaseModel):
email: str
role: str = "viewer"
display_name: Optional[str] = None
owned_node_ids: list[str] = []
class UserUpdate(BaseModel):
role: Optional[str] = None
owned_node_ids: Optional[list[str]] = None
display_name: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
if ms is None:
return None
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]:
claims = fb_user.custom_claims or {}
if claims.get("role") == "admin" or claims.get("admin"):
role = "admin"
else:
role = claims.get("role", "viewer")
if role not in VALID_ROLES:
role = "viewer"
owned_node_ids = claims.get("owned_node_ids") or []
return role, owned_node_ids
def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict:
role, owned_node_ids = _extract_role_nodes(fb_user)
return {
"uid": fb_user.uid,
"email": fb_user.email,
"display_name": fb_user.display_name,
"role": role,
"owned_node_ids": owned_node_ids,
"disabled": fb_user.disabled,
"creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp),
"last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp),
"discord_linked": bool(link and link.get("discord_user_id")),
"discord_username": link.get("discord_username") if link else None,
"discord_user_id": link.get("discord_user_id") if link else None,
}
def _list_fb_users() -> list[firebase_auth.UserRecord]:
users: list[firebase_auth.UserRecord] = []
page = firebase_auth.list_users()
while page:
users.extend(page.users)
page = page.get_next_page()
return users
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("")
async def list_users(decoded: dict = Depends(require_admin_token)):
"""List all Firebase Auth users with role, node ownership, and Discord link status."""
fb_users = await asyncio.to_thread(_list_fb_users)
links: list[Optional[dict]] = await asyncio.gather(*[
fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users
])
return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)]
@router.post("")
async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)):
"""Create a new Firebase Auth user and set their role. Returns a one-time invite link."""
if body.role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if body.role == "operator" and not body.owned_node_ids:
raise HTTPException(400, "Operator role requires at least one owned node.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
firebase_auth.create_user,
email=body.email,
display_name=body.display_name or "",
email_verified=False,
)
except firebase_auth.EmailAlreadyExistsError:
raise HTTPException(409, "A user with this email already exists.")
except Exception as e:
raise HTTPException(400, f"Failed to create user: {e}")
# Set custom claims
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
if body.role == "admin":
claims["admin"] = True
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
# Write Firestore profile
now = datetime.now(timezone.utc).isoformat()
await fstore.doc_set("user_profiles", fb_user.uid, {
"uid": fb_user.uid,
"email": body.email,
"display_name": body.display_name or "",
"role": body.role,
"owned_node_ids": body.owned_node_ids,
"created_by_uid": decoded["uid"],
"created_at": now,
}, merge=False)
# Generate a one-time invite/password-reset link
invite_link: Optional[str] = None
try:
invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email)
except Exception:
pass
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.create",
target_uid=fb_user.uid,
target_email=body.email,
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
)
return {**_format_user(fb_user), "invite_link": invite_link}
@router.get("/{uid}")
async def get_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Get a single user with full detail, including recent sessions."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
link, raw_sessions = await asyncio.gather(
fstore.doc_get("firebase_discord_links", uid),
fstore.collection_where("user_sessions", [("uid", "==", uid)]),
)
raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
return {
**_format_user(fb_user, link),
"sessions": raw_sessions[:20],
}
@router.patch("/{uid}")
async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)):
"""Update a user's role, owned nodes, or display name."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
current_role, current_nodes = _extract_role_nodes(fb_user)
new_role = body.role if body.role is not None else current_role
new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes
if new_role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if new_role == "operator" and not new_nodes:
raise HTTPException(400, "Operator role requires at least one owned node.")
# Merge with existing claims (preserve any other claims already set)
existing_claims: dict = dict(fb_user.custom_claims or {})
new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes}
if new_role == "admin":
new_claims["admin"] = True
else:
new_claims.pop("admin", None)
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
if body.display_name is not None:
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes}
if body.display_name is not None:
profile_data["display_name"] = body.display_name
await fstore.doc_set("user_profiles", uid, profile_data, merge=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.update",
target_uid=uid,
target_email=fb_user.email,
details={
"old_role": current_role,
"new_role": new_role,
"old_nodes": current_nodes,
"new_nodes": new_nodes,
},
)
updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
link = await fstore.doc_get("firebase_discord_links", uid)
return _format_user(updated, link)
@router.post("/{uid}/disable")
async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Disable a user — they can no longer sign in but their data is preserved."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot disable your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.disable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.post("/{uid}/enable")
async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Re-enable a previously disabled user."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.enable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.delete("/{uid}")
async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Permanently delete a user from Firebase Auth and clean up Firestore data."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot delete your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
email = fb_user.email
# Clean up Discord link if present
link = await fstore.doc_get("firebase_discord_links", uid)
if link and link.get("discord_user_id"):
await asyncio.gather(
fstore.doc_delete("discord_links", link["discord_user_id"]),
fstore.doc_delete("firebase_discord_links", uid),
)
# Delete Firestore profile (sessions are kept for audit history)
await fstore.doc_delete("user_profiles", uid)
# Delete from Firebase Auth
await asyncio.to_thread(firebase_auth.delete_user, uid)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.delete",
target_uid=uid,
target_email=email,
)
return {"ok": True}
+11
View File
@@ -4,6 +4,17 @@ WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install RUN npm install
COPY . . COPY . .
# Build-time public vars — baked into the Next.js bundle by the CI workflow
ARG NEXT_PUBLIC_C2_URL
ARG NEXT_PUBLIC_FIREBASE_API_KEY
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
ARG NEXT_PUBLIC_FIREBASE_APP_ID
ARG NEXT_PUBLIC_FIRESTORE_DATABASE
RUN npm run build RUN npm run build
FROM node:20-slim AS runner FROM node:20-slim AS runner
+833 -65
View File
@@ -2,8 +2,13 @@
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
// ---------------------------------------------------------------------------
// Shared primitives
// ---------------------------------------------------------------------------
interface FeatureFlags { interface FeatureFlags {
stt_enabled: boolean; stt_enabled: boolean;
@@ -61,6 +66,99 @@ function Toggle({
); );
} }
function fmtDate(iso: string | null | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function fmtDatetime(iso: string | null | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
});
}
const ROLE_COLORS: Record<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400",
};
function RoleBadge({ role }: { role: UserRole }) {
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
{labels[role]}
</span>
);
}
// ---------------------------------------------------------------------------
// AI Features tab
// ---------------------------------------------------------------------------
function FeaturesTab() {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as unknown as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
return (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
</div>
))}
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Correlation Debug tab
// ---------------------------------------------------------------------------
function CorrelationDebugTab() { function CorrelationDebugTab() {
const [limit, setLimit] = useState(20); const [limit, setLimit] = useState(20);
const [orphanHours, setOrphanHours] = useState(48); const [orphanHours, setOrphanHours] = useState(48);
@@ -121,7 +219,6 @@ function CorrelationDebugTab() {
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>; orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
} | null; } | null;
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
const pathCounts: Record<string, number> = {}; const pathCounts: Record<string, number> = {};
const signalCounts: Record<string, number> = {}; const signalCounts: Record<string, number> = {};
if (meta?.incidents) { if (meta?.incidents) {
@@ -245,91 +342,762 @@ function CorrelationDebugTab() {
); );
} }
export default function AdminPage() { // ---------------------------------------------------------------------------
const { isAdmin } = useAuth(); // User detail panel
const router = useRouter(); // ---------------------------------------------------------------------------
const [tab, setTab] = useState<"features" | "correlation">("features");
const [flags, setFlags] = useState<FeatureFlags | null>(null); function UserDetailPanel({
const [loading, setLoading] = useState(true); user,
const [saving, setSaving] = useState<string | null>(null); onClose,
onUpdated,
currentUid,
}: {
user: UserRecord;
onClose: () => void;
onUpdated: (u: UserRecord) => void;
currentUid: string;
}) {
const [detail, setDetail] = useState<UserRecord>(user);
const [editRole, setEditRole] = useState<UserRole>(user.role);
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
const [editName, setEditName] = useState<string>(user.display_name ?? "");
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showSessions, setShowSessions] = useState(false);
// Fetch full detail (sessions) lazily
useEffect(() => { useEffect(() => {
if (!isAdmin) { c2api.getUser(user.uid)
router.replace("/dashboard"); .then((d) => setDetail(d))
return; .catch(() => {});
} }, [user.uid]);
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [isAdmin, router]);
async function handleToggle(key: keyof FeatureFlags, value: boolean) { async function handleSave() {
if (!flags) return; setSaving(true);
setSaving(key);
setError(null); setError(null);
const nodes = editRole === "operator"
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try { try {
const updated = await c2api.setFeatureFlags({ [key]: value }); const updated = await c2api.updateUser(user.uid, {
setFlags(updated as unknown as FeatureFlags); role: editRole,
owned_node_ids: nodes,
display_name: editName || undefined,
});
onUpdated(updated);
setDetail((d) => ({ ...d, ...updated }));
} catch (e) { } catch (e) {
setError(String(e)); setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setSaving(null); setSaving(false);
} }
} }
if (!isAdmin) return null; async function handleToggleDisabled() {
setToggling(true);
setError(null);
try {
if (detail.disabled) {
await c2api.enableUser(user.uid);
} else {
await c2api.disableUser(user.uid);
}
const next = { ...detail, disabled: !detail.disabled };
setDetail(next);
onUpdated(next);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setToggling(false);
}
}
async function handleDelete() {
if (!confirm(`Permanently delete ${detail.email}? This cannot be undone.`)) return;
setDeleting(true);
setError(null);
try {
await c2api.deleteUser(user.uid);
onUpdated({ ...detail, uid: "__deleted__" });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDeleting(false);
}
}
const isSelf = user.uid === currentUid;
return ( return (
<div className="max-w-2xl space-y-6"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1> <div className="flex items-start justify-between">
<div>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit"> <p className="text-white font-semibold">{detail.email}</p>
{(["features", "correlation"] as const).map((t) => ( <p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
<button </div>
key={t} <button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : "Correlation Debug"}
</button>
))}
</div> </div>
{tab === "features" && ( {error && (
<section className="space-y-3"> <div className="bg-red-950 border border-red-800 rounded-lg p-3">
{error && ( <p className="text-red-400 text-xs">{error}</p>
<div className="bg-red-950 border border-red-800 rounded-lg p-3"> </div>
<p className="text-red-400 text-sm font-mono">{error}</p> )}
</div>
)} <div className="space-y-3">
{loading ? ( <div>
<p className="text-gray-500 text-sm font-mono">Loading</p> <label className="text-xs text-gray-400 block mb-1">Display Name</label>
) : ( <input
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800"> value={editName}
{FLAG_META.map(({ key, label, description }) => ( onChange={(e) => setEditName(e.target.value)}
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4"> className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
<div className="min-w-0"> placeholder="Full name"
<p className="text-white text-sm font-semibold">{label}</p> />
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p> </div>
</div>
<Toggle <div>
enabled={flags?.[key] ?? true} <label className="text-xs text-gray-400 block mb-1">Role</label>
onChange={(val) => handleToggle(key, val)} <select
disabled={saving === key} value={editRole}
/> onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{editRole === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={editNodes}
onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Discord</span>
<span className="text-gray-300">
{detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
</div>
</div>
{(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4">
<button
onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
>
<span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span>
</button>
{showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
</section> </div>
)} )}
{tab === "correlation" && <CorrelationDebugTab />} <div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
{!isSelf ? (
<>
<button
onClick={handleToggleDisabled}
disabled={toggling}
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
>
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting…" : "Delete user"}
</button>
</>
) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create User modal
// ---------------------------------------------------------------------------
function CreateUserModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (u: UserRecord) => void;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [nodeIds, setNodeIds] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const owned_node_ids = role === "operator"
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try {
const created = await c2api.createUser({
email,
role,
display_name: displayName || undefined,
owned_node_ids,
});
onCreated(created);
if (created.invite_link) {
setInviteLink(created.invite_link);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
function copyLink() {
if (!inviteLink) return;
navigator.clipboard?.writeText(inviteLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400">
Share this one-time invite link with the new user so they can set their password.
It expires after use.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<div className="flex gap-3">
<button
onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
{copied ? "Copied!" : "Copy link"}
</button>
<button
onClick={onClose}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Display Name <span className="text-gray-600">(optional)</span>
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{role === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Creating…" : "Create user"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
function UsersTab({ currentUid }: { currentUid: string }) {
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUid, setSelectedUid] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
try {
const data = await c2api.listUsers();
setUsers(data);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
function handleUpdated(updated: UserRecord) {
if (updated.uid === "__deleted__") {
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
setSelectedUid(null);
} else {
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
}
}
function handleCreated(created: UserRecord) {
setUsers((prev) => [...prev, created]);
}
const selectedUser = users.find((u) => u.uid === selectedUid);
return (
<div className="space-y-4">
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
/>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
+ Create user
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p>
) : (
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.uid}
className={`border-t border-gray-800 transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
}`}
>
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5">
{u.disabled
? <span className="text-red-500">Disabled</span>
: <span className="text-green-500">Active</span>
}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
{selectedUid === u.uid ? "Close" : "Edit"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Audit Log tab
// ---------------------------------------------------------------------------
function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const PAGE = 50;
useEffect(() => {
c2api.getAuditLog(PAGE, 0)
.then((data) => {
setEntries(data);
setHasMore(data.length === PAGE);
})
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function loadMore() {
setLoadingMore(true);
try {
const more = await c2api.getAuditLog(PAGE, entries.length);
setEntries((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
} catch (e) {
setError(String(e));
} finally {
setLoadingMore(false);
}
}
function actionColor(action: string) {
if (action.includes("delete")) return "text-red-400";
if (action.includes("disable")) return "text-yellow-400";
if (action.includes("create")) return "text-green-400";
return "text-indigo-400";
}
return (
<div className="space-y-4">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
) : (
<>
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
<th className="px-4 py-2.5 text-left">Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{Object.keys(e.details).length > 0
? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(" · ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Stale Calls tab
// ---------------------------------------------------------------------------
function StaleCallsTab() {
const [minutes, setMinutes] = useState(30);
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(dryRun: boolean) {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await c2api.closeStallCalls(minutes, dryRun);
setResult(res);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5">
<p className="text-xs text-gray-500 font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
<input
type="number"
min={1} max={1440}
value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={() => run(true)}
disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Working…" : "Preview"}
</button>
<button
onClick={() => run(false)}
disabled={loading || result === null || result.count === 0}
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""}
</span>
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>}
</p>
{result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
))}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "calls", label: "Calls" },
{ key: "users", label: "Users" },
{ key: "audit", label: "Audit Log" },
];
export default function AdminPage() {
const { user, isAdmin } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
useEffect(() => {
if (!isAdmin) router.replace("/dashboard");
}, [isAdmin, router]);
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{label}
</button>
))}
</div>
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div> </div>
); );
} }
+3
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import { c2api } from "@/lib/c2api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
@@ -18,6 +19,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithEmailAndPassword(auth, email, password); await signInWithEmailAndPassword(auth, email, password);
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Invalid email or password."); setError("Invalid email or password.");
@@ -31,6 +33,7 @@ export default function LoginPage() {
setError(null); setError(null);
try { try {
await signInWithPopup(auth, new GoogleAuthProvider()); await signInWithPopup(auth, new GoogleAuthProvider());
c2api.recordSession().catch(() => {});
router.push("/dashboard"); router.push("/dashboard");
} catch { } catch {
setError("Google sign-in failed. Try again."); setError("Google sign-in failed. Try again.");
+11 -1
View File
@@ -1,15 +1,25 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { NodeCard } from "@/components/NodeCard"; import { NodeCard } from "@/components/NodeCard";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { useAuth } from "@/components/AuthProvider";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
export default function NodesPage() { export default function NodesPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { nodes, loading } = useNodes(); const { nodes, loading } = useNodes();
const { systems } = useSystems(); const { systems } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, isOperator, router]);
if (authLoading || (!isAdmin && !isOperator)) return null;
const [configNode, setConfigNode] = useState<NodeRecord | null>(null); const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
interface LinkStatus {
linked: boolean;
discord_user_id?: string;
discord_username?: string;
linked_at?: string;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function Initials({ name }: { name: string }) {
const parts = name.trim().split(/\s+/);
const letters = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: name.slice(0, 2);
return (
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
{letters.toUpperCase()}
</div>
);
}
export default function ProfilePage() {
const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
const [linkLoading, setLinkLoading] = useState(true);
const [code, setCode] = useState<string | null>(null);
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
const [generating, setGenerating] = useState(false);
const [unlinking, setUnlinking] = useState(false);
useEffect(() => {
if (!user) return;
c2api.getLinkStatus()
.then(setLinkStatus)
.catch(() => setLinkStatus({ linked: false }))
.finally(() => setLinkLoading(false));
}, [user]);
async function generateCode() {
setGenerating(true);
try {
const res = await c2api.generateLinkCode();
if (res.already_linked) {
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
} else if (res.code) {
setCode(res.code);
setCodeExpiry(res.expires_minutes ?? 15);
}
} finally {
setGenerating(false);
}
}
async function unlink() {
setUnlinking(true);
try {
await c2api.unlinkDiscord();
setLinkStatus({ linked: false });
setCode(null);
} finally {
setUnlinking(false);
}
}
async function handleSignOut() {
await signOut();
router.push("/login");
}
if (!user) return null;
const displayName = user.displayName || user.email || "Account";
return (
<div className="max-w-lg space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Initials name={displayName} />
<div>
<h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
)}
{role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400"
}`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span>
)}
</div>
</div>
{/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate />
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
{user.metadata.creationTime && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)}
{user.metadata.lastSignInTime && (
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
)}
</div>
</div>
</section>
{/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : linkStatus?.linked ? (
<div className="space-y-3">
<div className="space-y-2">
{linkStatus.discord_username && (
<Row label="Username" value={`@${linkStatus.discord_username}`} />
)}
{linkStatus.discord_user_id && (
<Row label="User ID" value={linkStatus.discord_user_id} mono />
)}
{linkStatus.linked_at && (
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
)}
</div>
<div className="pt-1">
<button
onClick={unlink}
disabled={unlinking}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
>
{unlinking ? "Unlinking…" : "Unlink Discord account"}
</button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Link your Discord account to access private trips from both the web and Discord.
</p>
{code ? (
<div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div>
<p className="text-xs text-gray-400">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p>
<button
onClick={generateCode}
disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
>
Generate new code
</button>
</div>
) : (
<button
onClick={generateCode}
disabled={generating}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
{generating ? "Generating…" : "Get link code"}
</button>
)}
</div>
)}
</div>
</section>
{/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl">
<div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors"
>
Sign out
</button>
</div>
</section>
</div>
);
}
function Row({ label, value, mono = false, truncate = false }: {
label: string;
value: string;
mono?: boolean;
truncate?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value}
</span>
</div>
);
}
+11 -1
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useRef, useState, Fragment } from "react"; import { useEffect, useRef, useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ─────────────────────────────────────────────── // ── P25 structured config types ───────────────────────────────────────────────
@@ -1178,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
// ── Systems list page ───────────────────────────────────────────────────────── // ── Systems list page ─────────────────────────────────────────────────────────
export default function SystemsPage() { export default function SystemsPage() {
const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter();
const { systems, loading } = useSystems(); const { systems, loading } = useSystems();
useEffect(() => {
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, isOperator, router]);
if (authLoading || (!isAdmin && !isOperator)) return null;
const [editing, setEditing] = useState<SystemRecord | null | "new">(null); const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false); const [editIsDuplicate, setEditIsDuplicate] = useState(false);
+4 -4
View File
@@ -15,7 +15,7 @@ interface TokenRecord {
} }
export default function TokensPage() { export default function TokensPage() {
const { isAdmin, loading: authLoading } = useAuth(); const { isAdmin, isOperator, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const [tokens, setTokens] = useState<TokenRecord[]>([]); const [tokens, setTokens] = useState<TokenRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -26,8 +26,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!authLoading && !isAdmin) router.replace("/dashboard"); if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
}, [authLoading, isAdmin, router]); }, [authLoading, isAdmin, isOperator, router]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -67,7 +67,7 @@ export default function TokensPage() {
} }
} }
if (authLoading || !isAdmin) return null; if (authLoading || (!isAdmin && !isOperator)) return null;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api";
import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) {
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function TripCard({ trip, isAdmin, onDelete }: {
trip: TripRecord;
isAdmin: boolean;
onDelete: (id: string) => void;
}) {
const router = useRouter();
const today = new Date().toISOString().slice(0, 10);
const upcoming = trip.start_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return (
<div
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
onClick={() => router.push(`/trips/${trip.trip_id}`)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
}`}>
{upcoming ? "Upcoming" : "Past"}
</span>
</div>
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
<p className="text-gray-500 text-xs font-mono mt-1">
{fmtDate(trip.start_date)} {fmtDate(trip.end_date)}
</p>
{attendeeCount > 0 && (
<p className="text-gray-500 text-xs mt-1">
{attendeeCount} going
</p>
)}
</div>
{isAdmin && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(trip.trip_id); }}
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
>
Delete
</button>
)}
</div>
</div>
);
}
function CreateModal({ onClose, onCreate }: {
onClose: () => void;
onCreate: (body: object) => Promise<void>;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [mapsLink, setMapsLink] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (end < start) { setError("End date must be on or after start date."); return; }
setSaving(true);
setError(null);
try {
await onCreate({
name,
location,
start_date: start,
end_date: end,
maps_link: mapsLink || null,
});
onClose();
} catch {
setError("Failed to create trip.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
>
<h2 className="text-white font-bold">New Trip</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<input
required value={name} onChange={(e) => setName(e.target.value)}
placeholder="Road trip to Nashville"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Location</label>
<input
required value={location} onChange={(e) => setLocation(e.target.value)}
placeholder="Nashville, TN"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Start date</label>
<input
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">End date</label>
<input
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
min={start}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
<input
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
placeholder="https://maps.google.com/…"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? "Creating…" : "Create Trip"}
</button>
</div>
</form>
</div>
);
}
export default function TripsPage() {
const { isAdmin } = useAuth();
const { trips, loading } = useTrips();
const [showCreate, setShowCreate] = useState(false);
const today = new Date().toISOString().slice(0, 10);
const upcoming = trips.filter((t) => t.end_date >= today);
const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) {
try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); }
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-bold font-mono">Trips</h1>
{isAdmin && (
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
+ New Trip
</button>
)}
</div>
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<>
{upcoming.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
<div className="space-y-3">
{upcoming.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{past.length > 0 && (
<section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
<div className="space-y-3">
{past.map((t) => (
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
))}
</div>
</section>
)}
{trips.length === 0 && (
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
)}
</>
)}
{showCreate && (
<CreateModal
onClose={() => setShowCreate(false)}
onCreate={async (body) => { await c2api.createTrip(body); }}
/>
)}
</div>
);
}
+29 -5
View File
@@ -3,25 +3,33 @@
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
import { auth } from "@/lib/firebase"; import { auth } from "@/lib/firebase";
import type { UserRole } from "@/lib/types";
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
role: UserRole | null;
isAdmin: boolean; isAdmin: boolean;
isOperator: boolean;
ownedNodeIds: string[];
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
user: null, user: null,
loading: true, loading: true,
role: null,
isAdmin: false, isAdmin: false,
isOperator: false,
ownedNodeIds: [],
signOut: async () => {}, signOut: async () => {},
}); });
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
return onAuthStateChanged(auth, async (u) => { return onAuthStateChanged(auth, async (u) => {
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (u) { if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict"; document.cookie = "drb_session=1; path=/; SameSite=Strict";
// Read custom claims to determine admin status
const result = await u.getIdTokenResult(true); const result = await u.getIdTokenResult(true);
setIsAdmin(!!result.claims.admin); const claims = result.claims;
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
let effectiveRole: UserRole = "viewer";
if (claims.role === "admin" || claims.admin) {
effectiveRole = "admin";
} else if (claims.role === "operator") {
effectiveRole = "operator";
} else if (claims.role === "viewer") {
effectiveRole = "viewer";
}
setRole(effectiveRole);
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
} else { } else {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
setIsAdmin(false); setRole(null);
setOwnedNodeIds([]);
} }
}); });
}, []); }, []);
@@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
document.cookie = "drb_session=; path=/; max-age=0"; document.cookie = "drb_session=; path=/; max-age=0";
} }
const isAdmin = role === "admin";
const isOperator = role === "operator";
return ( return (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}> <AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+42 -22
View File
@@ -2,25 +2,32 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes"; import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
const links = [ // Links visible to all authenticated roles (viewer+)
{ href: "/dashboard", label: "Dashboard" }, const viewerLinks = [
{ href: "/nodes", label: "Nodes" }, { href: "/dashboard", label: "Dashboard" },
{ href: "/systems", label: "Systems" }, { href: "/calls", label: "Calls" },
{ href: "/calls", label: "Calls" }, { href: "/incidents", label: "Incidents" },
{ href: "/incidents", label: "Incidents" }, { href: "/map", label: "Map" },
{ href: "/map", label: "Map" }, { href: "/alerts", label: "Alerts" },
{ href: "/alerts", label: "Alerts" }, { href: "/trips", label: "Trips" },
]; ];
// Additional links for operators and admins
const operatorLinks = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/tokens", label: "Tokens" },
];
// Admin-only links
const adminLinks = [ const adminLinks = [
{ href: "/tokens", label: "Tokens" }, { href: "/admin", label: "Admin" },
{ href: "/admin", label: "Admin" },
]; ];
function SunIcon() { function SunIcon() {
@@ -48,8 +55,9 @@ function MoonIcon() {
} }
export function Nav() { export function Nav() {
const { user, isAdmin, signOut } = useAuth(); const { user, isAdmin, isOperator } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter();
const { nodes: pending } = useUnconfiguredNodes(); const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts(); const unackedAlerts = useUnacknowledgedAlerts();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
@@ -57,7 +65,11 @@ export function Nav() {
if (!user) return null; if (!user) return null;
const allLinks = [...links, ...(isAdmin ? adminLinks : [])]; const allLinks = [
...viewerLinks,
...(isAdmin || isOperator ? operatorLinks : []),
...(isAdmin ? adminLinks : []),
];
function navLinkClass(href: string) { function navLinkClass(href: string) {
return `text-sm font-mono transition-colors shrink-0 ${ return `text-sm font-mono transition-colors shrink-0 ${
@@ -100,12 +112,17 @@ export function Nav() {
{theme === "dark" ? <SunIcon /> : <MoonIcon />} {theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button> </button>
{/* Sign out (desktop) */} {/* Profile avatar (desktop) */}
<button <button
onClick={signOut} onClick={() => router.push("/profile")}
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
pathname.startsWith("/profile")
? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
title="Profile"
> >
Sign out {(user?.displayName || user?.email || "?")[0].toUpperCase()}
</button> </button>
{/* Hamburger (mobile) */} {/* Hamburger (mobile) */}
@@ -153,12 +170,15 @@ export function Nav() {
</Link> </Link>
))} ))}
<div className="border-t border-gray-800 pt-3 mt-1"> <div className="border-t border-gray-800 pt-3 mt-1">
<button <Link
onClick={signOut} href="/profile"
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors" onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
}`}
> >
Sign out Profile
</button> </Link>
</div> </div>
</div> </div>
)} )}
+76
View File
@@ -63,6 +63,8 @@ export const c2api = {
}, },
patchTranscript: (callId: string, transcript: string) => patchTranscript: (callId: string, transcript: string) =>
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }), request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
// Incidents // Incidents
getIncidents: (params?: { status?: string; type?: string }) => { getIncidents: (params?: { status?: string; type?: string }) => {
@@ -134,10 +136,84 @@ export const c2api = {
setPreferredToken: (tokenId: string, systemId: string) => setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }), request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
// Trips
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
getTrip: (id: string) =>
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
createTrip: (body: object) =>
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
deleteTrip: (id: string) =>
request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
setTripVisibility: (id: string, visibility: "public" | "private") =>
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
inviteToTrip: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
revokeInvite: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
generateLinkCode: () =>
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
getLinkStatus: () =>
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
unlinkDiscord: () =>
request("/auth/link", { method: "DELETE" }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTripEvent: (tripId: string, eventId: string) =>
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
`/trips/${tripId}/chat`,
{ method: "POST", body: JSON.stringify({ message, history }) }
),
// Places
searchPlaces: (query: string, near: string) =>
request<import("@/lib/types").PlaceResult[]>(
`/places/search?${new URLSearchParams({ query, near }).toString()}`
),
getDirections: (origin: string, destination: string) =>
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
),
// Per-system AI flag overrides // Per-system AI flag overrides
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) => setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, { request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
method: "PUT", method: "PUT",
body: JSON.stringify(flags), body: JSON.stringify(flags),
}), }),
// User management (admin only)
listUsers: () =>
request<import("@/lib/types").UserRecord[]>("/admin/users"),
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
method: "POST",
body: JSON.stringify(body),
}),
getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
disableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
enableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
deleteUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
// Audit log (admin only)
getAuditLog: (limit = 50, offset = 0) =>
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
// Session recording — called on each explicit sign-in
recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
}; };
+82
View File
@@ -1,5 +1,44 @@
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured"; export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
export type ApprovalStatus = "pending" | "approved" | "rejected"; export type ApprovalStatus = "pending" | "approved" | "rejected";
export type UserRole = "admin" | "operator" | "viewer";
export interface UserRecord {
uid: string;
email: string | null;
display_name: string | null;
role: UserRole;
owned_node_ids: string[];
disabled: boolean;
creation_time: string | null;
last_sign_in: string | null;
discord_linked: boolean;
discord_username: string | null;
discord_user_id: string | null;
// only present on GET /admin/users/{uid}
sessions?: UserSession[];
// only present on POST /admin/users response
invite_link?: string | null;
}
export interface UserSession {
session_id: string;
uid: string;
email: string;
timestamp: string;
ip: string | null;
user_agent: string | null;
}
export interface AuditEntry {
log_id: string;
action: string;
actor_uid: string;
actor_email: string;
target_uid: string | null;
target_email: string | null;
details: Record<string, unknown>;
timestamp: string;
}
export interface NodeRecord { export interface NodeRecord {
node_id: string; node_id: string;
@@ -98,6 +137,49 @@ export interface AlertRule {
created_at?: string; created_at?: string;
} }
export interface TripEvent {
event_id: string;
trip_id: string;
title: string;
date: string;
start_time: string | null;
end_time: string | null;
location: string;
location_inherited: boolean;
maps_link: string | null;
place_id: string | null;
notes: string | null;
tags: string[];
attendees: Record<string, string>;
created_at: string;
}
export interface PlaceResult {
name: string;
address: string;
place_id: string;
lat: number;
lng: number;
maps_link: string;
rating?: number;
}
export interface TripRecord {
trip_id: string;
name: string;
location: string;
maps_link: string | null;
start_date: string;
end_date: string;
attendees: Record<string, string>;
available_tags: string[];
overlap_tags: string[];
visibility: "public" | "private";
invited_discord_ids: string[];
created_at: string;
events?: TripEvent[];
}
export interface AlertEvent { export interface AlertEvent {
alert_id: string; alert_id: string;
rule_id: string; rule_id: string;
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase";
import type { TripRecord } from "@/lib/types";
export function useTrips() {
const [trips, setTrips] = useState<TripRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setTrips([]); setLoading(false); return; }
const q = query(collection(db, "trips"), orderBy("start_date", "asc"));
unsubFirestore = onSnapshot(
q,
(snap) => {
setTrips(snap.docs.map((d) => d.data() as TripRecord));
setLoading(false);
},
(err) => {
console.error("useTrips:", err);
setLoading(false);
}
);
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, []);
return { trips, loading };
}
+2 -1
View File
@@ -14,7 +14,8 @@
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"firebase": "^10.12.0", "firebase": "^10.12.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1" "react-leaflet": "^4.2.1",
"react-markdown": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0", "typescript": "^5.4.0",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
+115 -19
View File
@@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str):
# Cog # Cog
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
if trip.get("visibility", "public") == "public":
return True
if discord_user_id in trip.get("attendees", {}):
return True
if discord_user_id in trip.get("invited_discord_ids", []):
return True
return False
class TripCommands(commands.Cog): class TripCommands(commands.Cog):
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
@@ -79,10 +89,11 @@ class TripCommands(commands.Cog):
self, interaction: discord.Interaction, current: str self, interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]: ) -> list[app_commands.Choice[str]]:
trips = await c2.get_trips() trips = await c2.get_trips()
user_id = str(interaction.user.id)
return [ return [
app_commands.Choice(name=t["name"], value=t["trip_id"]) app_commands.Choice(name=t["name"], value=t["trip_id"])
for t in trips for t in trips
if current.lower() in t["name"].lower() if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
][:25] ][:25]
async def event_autocomplete( async def event_autocomplete(
@@ -172,6 +183,9 @@ class TripCommands(commands.Cog):
today = date.today().strftime("%Y-%m-%d") today = date.today().strftime("%Y-%m-%d")
trips.sort(key=lambda t: t.get("start_date", "")) trips.sort(key=lambda t: t.get("start_date", ""))
user_id = str(interaction.user.id)
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
embed = discord.Embed(title="Trips", color=0x2b2d31) embed = discord.Embed(title="Trips", color=0x2b2d31)
for t in trips[:10]: for t in trips[:10]:
upcoming = t.get("start_date", "") >= today upcoming = t.get("start_date", "") >= today
@@ -181,8 +195,9 @@ class TripCommands(commands.Cog):
f"{_fmt_date(t.get('end_date', ''))}" f"{_fmt_date(t.get('end_date', ''))}"
) )
attendee_count = len(t.get("attendees", {})) attendee_count = len(t.get("attendees", {}))
field_name = f"{t['name']} [{status}]"[:256]
embed.add_field( embed.add_field(
name=f"{t['name']} [{status}]", name=field_name,
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going", value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
inline=False, inline=False,
) )
@@ -203,6 +218,9 @@ class TripCommands(commands.Cog):
if not data: if not data:
await interaction.followup.send("Trip not found.") await interaction.followup.send("Trip not found.")
return return
if not _user_can_see_trip(data, str(interaction.user.id)):
await interaction.followup.send("This trip is private.", ephemeral=True)
return
attendee_names = list(data.get("attendees", {}).values()) attendee_names = list(data.get("attendees", {}).values())
desc_lines = [ desc_lines = [
@@ -215,8 +233,8 @@ class TripCommands(commands.Cog):
) )
embed = discord.Embed( embed = discord.Embed(
title=data["name"], title=data["name"][:256],
description="\n".join(desc_lines), description="\n".join(desc_lines)[:4096],
color=0x5865f2, color=0x5865f2,
) )
@@ -225,19 +243,21 @@ class TripCommands(commands.Cog):
for e in data.get("events", []): for e in data.get("events", []):
events_by_date.setdefault(e["date"], []).append(e) events_by_date.setdefault(e["date"], []).append(e)
# Track total embed chars (Discord limit: 6000)
embed_chars = len(embed.title or "") + len(embed.description or "")
field_count = 0 field_count = 0
for day_iso in _date_range(data["start_date"], data["end_date"]): for day_iso in _date_range(data["start_date"], data["end_date"]):
day_events = events_by_date.get(day_iso) day_events = events_by_date.get(day_iso)
if not day_events: if not day_events:
continue continue
if field_count >= 24: if field_count >= 24 or embed_chars >= 5800:
embed.add_field(name="...", value="More events not shown.", inline=False) embed.add_field(name="...", value="More events not shown.", inline=False)
break break
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d") day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
lines = [] lines = []
for e in sorted(day_events, key=lambda x: x.get("time") or ""): for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
time_str = _fmt_time(e.get("time")) time_str = _fmt_time(e.get("start_time"))
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}" line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
loc = e.get("location") loc = e.get("location")
@@ -248,13 +268,22 @@ class TripCommands(commands.Cog):
if e.get("notes"): if e.get("notes"):
line += f"\n\u3000\u3000_{e['notes']}_" line += f"\n\u3000\u3000_{e['notes']}_"
event_tags = e.get("tags") or []
if event_tags:
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
event_att = list(e.get("attendees", {}).values()) event_att = list(e.get("attendees", {}).values())
if event_att: if event_att:
line += f"\n\u3000\u3000{', '.join(event_att)}" line += f"\n\u3000\u3000{', '.join(event_att)}"
lines.append(line) lines.append(line)
embed.add_field(name=f"{day_label}", value="\n".join(lines), inline=False) field_name = f"{day_label}"
field_value = "\n".join(lines)
if len(field_value) > 1024:
field_value = field_value[:1021] + ""
embed.add_field(name=field_name, value=field_value, inline=False)
embed_chars += len(field_name) + len(field_value)
field_count += 1 field_count += 1
if not events_by_date: if not events_by_date:
@@ -290,9 +319,11 @@ class TripCommands(commands.Cog):
@app_commands.autocomplete(trip=trip_autocomplete) @app_commands.autocomplete(trip=trip_autocomplete)
async def trip_join(self, interaction: discord.Interaction, trip: str): async def trip_join(self, interaction: discord.Interaction, trip: str):
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
if ok: if result is True:
await interaction.followup.send("You're on the trip!") await interaction.followup.send("You're on the trip!")
elif result == "private":
await interaction.followup.send("This trip is private — you need an invite to join.")
else: else:
await interaction.followup.send("Failed to join trip.") await interaction.followup.send("Failed to join trip.")
@@ -316,7 +347,8 @@ class TripCommands(commands.Cog):
trip="The trip to add this event to.", trip="The trip to add this event to.",
title="Event title", title="Event title",
date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)", date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)",
time="Time of the event (e.g. 14:00 or 2:00 PM) — optional", start_time="Start time (e.g. 14:00 or 2:00 PM) — optional",
end_time="End time (e.g. 16:00 or 4:00 PM) — optional",
location="Location override (optional, inherits trip location if omitted)", location="Location override (optional, inherits trip location if omitted)",
maps_link="Google Maps link for this event (optional)", maps_link="Google Maps link for this event (optional)",
notes="Any additional notes (optional)", notes="Any additional notes (optional)",
@@ -328,7 +360,8 @@ class TripCommands(commands.Cog):
trip: str, trip: str,
title: str, title: str,
date: str, date: str,
time: Optional[str] = None, start_time: Optional[str] = None,
end_time: Optional[str] = None,
location: Optional[str] = None, location: Optional[str] = None,
maps_link: Optional[str] = None, maps_link: Optional[str] = None,
notes: Optional[str] = None, notes: Optional[str] = None,
@@ -340,17 +373,21 @@ class TripCommands(commands.Cog):
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.") await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
return return
parsed_time = _parse_time(time) if time else None parsed_start = _parse_time(start_time) if start_time else None
if time and parsed_time is None: parsed_end = _parse_time(end_time) if end_time else None
await interaction.followup.send(
"Couldn't parse that time. Try `14:00` or `2:00 PM`." if start_time and parsed_start is None:
) await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.")
return
if end_time and parsed_end is None:
await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.")
return return
event = await c2.create_trip_event(trip, { event = await c2.create_trip_event(trip, {
"title": title, "title": title,
"date": parsed_date.strftime("%Y-%m-%d"), "date": parsed_date.strftime("%Y-%m-%d"),
"time": parsed_time, "start_time": parsed_start,
"end_time": parsed_end,
"location": location, "location": location,
"maps_link": maps_link, "maps_link": maps_link,
"notes": notes, "notes": notes,
@@ -362,7 +399,7 @@ class TripCommands(commands.Cog):
) )
return return
time_display = f" at {_fmt_time(parsed_time)}" if parsed_time else "" time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else ""
await interaction.followup.send( await interaction.followup.send(
f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}." f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}."
) )
@@ -415,5 +452,64 @@ class TripCommands(commands.Cog):
await interaction.followup.send("Failed to leave event.") await interaction.followup.send("Failed to leave event.")
# ------------------------------------------------------------------
# /trip invite
# ------------------------------------------------------------------
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
@app_commands.describe(trip="The trip.", user="The user to invite.")
@app_commands.autocomplete(trip=trip_autocomplete)
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
await interaction.response.defer(ephemeral=True)
ok = await c2.invite_to_trip(trip, str(user.id))
if ok:
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
else:
await interaction.followup.send("Failed to send invite.")
# ------------------------------------------------------------------
# /trip privacy
# ------------------------------------------------------------------
@trip_group.command(name="privacy", description="Set a trip to public or private.")
@app_commands.describe(trip="The trip.", visibility="public or private")
@app_commands.autocomplete(trip=trip_autocomplete)
@app_commands.choices(visibility=[
app_commands.Choice(name="Public — anyone can see and join", value="public"),
app_commands.Choice(name="Private — invite only", value="private"),
])
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
await interaction.response.defer(ephemeral=True)
ok = await c2.set_trip_visibility(trip, visibility)
if ok:
await interaction.followup.send(f"Trip is now **{visibility}**.")
else:
await interaction.followup.send("Failed to update trip privacy.")
# ------------------------------------------------------------------
# /link
# ------------------------------------------------------------------
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
async def link_account(self, interaction: discord.Interaction, code: str):
await interaction.response.defer(ephemeral=True)
result = await c2.link_discord_account(
code.upper().strip(),
str(interaction.user.id),
interaction.user.display_name,
)
if "error" in result:
msgs = {
"invalid_code": "Invalid code. Generate a new one from the web app.",
"expired": "Code has expired. Generate a new one from the web app.",
"already_linked": "This Discord account is already linked to a different web account.",
"failed": "Something went wrong. Try again.",
}
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
else:
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
async def setup(bot: commands.Bot): async def setup(bot: commands.Bot):
await bot.add_cog(TripCommands(bot)) await bot.add_cog(TripCommands(bot))
@@ -112,7 +112,55 @@ class C2Client:
logger.error(f"C2 delete_trip failed: {e}") logger.error(f"C2 delete_trip failed: {e}")
return False return False
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool: async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 invite_to_trip failed: {e}")
return False
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.put(
f"{self.base}/trips/{trip_id}/visibility",
json={"visibility": visibility},
headers=self._headers(),
)
r.raise_for_status()
return True
except Exception as e:
logger.error(f"C2 set_trip_visibility failed: {e}")
return False
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base}/auth/link",
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
headers=self._headers(),
)
if r.status_code == 404:
return {"error": "invalid_code"}
if r.status_code == 410:
return {"error": "expired"}
if r.status_code == 409:
return {"error": "already_linked"}
r.raise_for_status()
return r.json()
except Exception as e:
logger.error(f"C2 link_discord_account failed: {e}")
return {"error": "failed"}
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
"""Returns True on success, 'private' on 403, False on other errors."""
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
r = await client.post( r = await client.post(
@@ -120,6 +168,8 @@ class C2Client:
json={"discord_user_id": user_id, "discord_username": username}, json={"discord_user_id": user_id, "discord_username": username},
headers=self._headers(), headers=self._headers(),
) )
if r.status_code == 403:
return "private"
r.raise_for_status() r.raise_for_status()
return True return True
except Exception as e: except Exception as e:
+14
View File
@@ -0,0 +1,14 @@
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
# Caddy handles TLS automatically via Let's Encrypt.
api.{$DRB_DOMAIN} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{$DRB_DOMAIN} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
+40
View File
@@ -0,0 +1,40 @@
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
ANSIBLE_DIR = ansible
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
# ── Terraform ─────────────────────────────────────────────────────────────────
tf-init:
terraform init
tf-plan:
terraform plan
tf-apply:
terraform apply
@echo ""
@echo "Server IP: $$(terraform output -raw server_ip)"
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
tf-destroy:
@echo "WARNING: This will destroy the VM and all data on it."
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
# ── Ansible ───────────────────────────────────────────────────────────────────
# First-time setup: waits for Docker, clones repo, starts stack.
setup:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
# Update deploy: sync code + restart changed containers. Run this after every push.
deploy:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
# ── Helpers ───────────────────────────────────────────────────────────────────
ip:
@terraform output -raw server_ip
ssh:
ssh drb@$$(terraform output -raw server_ip)
+15
View File
@@ -0,0 +1,15 @@
---
# Lightweight update deploy — runs in ~60s.
# Use this for every code push after the initial site.yml run.
#
# Usage:
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
- name: Deploy DRB update
hosts: drb
become: true
vars_files:
- vault.yml
roles:
- deploy
+9
View File
@@ -0,0 +1,9 @@
# Copy to group_vars/all.yml — safe to commit (no secrets here).
domain: example.com # must match Terraform var.domain
app_dir: /opt/drb
ssh_user: drb
# Path to the local repo root on your machine (used for rsync).
# Trailing slash is intentional — rsync copies contents, not the folder itself.
local_repo_path: "/path/to/Version 5C/Server/"
+8
View File
@@ -0,0 +1,8 @@
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
# Get it with: cd ../terraform && terraform output server_ip
[drb]
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
[drb:vars]
ansible_python_interpreter=/usr/bin/python3
+77
View File
@@ -0,0 +1,77 @@
---
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
# Images are built and pushed by Gitea CI — this role never builds on the VM.
- name: Clone repo (skipped if already present)
git:
repo: "{{ repo_url }}"
dest: "{{ app_dir }}"
version: main
update: false
become: false
- name: Set ownership of app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
recurse: true
- name: Template top-level .env (docker-compose MQTT creds + registry)
template:
src: root.env.j2
dest: "{{ app_dir }}/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template c2-core .env
template:
src: c2-core.env.j2
dest: "{{ app_dir }}/drb-c2-core/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template discord-bot .env
template:
src: discord-bot.env.j2
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template frontend .env
template:
src: frontend.env.j2
dest: "{{ app_dir }}/drb-frontend/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Deploy Caddyfile
template:
src: Caddyfile.j2
dest: /etc/caddy/Caddyfile
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Log in to container registry
command: >
docker login {{ vault_registry_host }}
-u {{ vault_registry_user }}
-p {{ vault_registry_token }}
no_log: true
- name: Pull pre-built images and start stack
community.docker.docker_compose_v2:
project_src: "{{ app_dir }}"
files:
- docker-compose.yml
- docker-compose.prod.yml
pull: always
build: never
state: present
@@ -0,0 +1,13 @@
# Managed by Ansible — do not edit manually on the server.
api.{{ domain }} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{{ domain }} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
@@ -0,0 +1,20 @@
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
MQTT_BROKER=mosquitto
MQTT_PORT=1883
MQTT_USER={{ vault_mqtt_c2_user }}
MQTT_PASS={{ vault_mqtt_c2_pass }}
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
FIRESTORE_DATABASE={{ vault_firestore_database }}
GCS_BUCKET={{ vault_gcs_bucket }}
OPENAI_API_KEY={{ vault_openai_api_key }}
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
GEMINI_API_KEY={{ vault_gemini_api_key }}
SERVICE_KEY={{ vault_service_key }}
NODE_API_KEY={{ vault_node_api_key }}
CORS_ORIGINS=["https://app.{{ domain }}"]
@@ -0,0 +1,5 @@
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
DISCORD_TOKEN={{ vault_discord_token }}
C2_URL=http://c2-core:8000
C2_SERVICE_KEY={{ vault_service_key }}
@@ -0,0 +1,11 @@
# drb-frontend environment — Managed by Ansible. Do not edit manually.
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
@@ -0,0 +1,10 @@
# Top-level docker-compose environment — MQTT credentials and registry prefix.
# Managed by Ansible. Do not edit manually.
MQTT_C2_USER={{ vault_mqtt_c2_user }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
MQTT_NODE_USER={{ vault_mqtt_node_user }}
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
REGISTRY={{ vault_registry }}
+79
View File
@@ -0,0 +1,79 @@
---
# Full first-time setup: waits for the VM's startup.sh to finish installing
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
#
# Usage:
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
- name: Bootstrap + deploy DRB server
hosts: drb
become: true
vars_files:
- vault.yml
pre_tasks:
- name: Install rsync
apt:
name: rsync
state: present
update_cache: false
- name: Wait for Docker (startup.sh runs async on first boot)
command: docker info
register: _docker
until: _docker.rc == 0
retries: 30
delay: 10
changed_when: false
- name: Create 2 GB swap file
command: fallocate -l 2G /swapfile
args:
creates: /swapfile
- name: Set swap file permissions
file:
path: /swapfile
mode: "0600"
- name: Format swap file
command: mkswap /swapfile
register: _mkswap
changed_when: _mkswap.rc == 0
- name: Enable swap
command: swapon /swapfile
register: _swapon
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
changed_when: _swapon.rc == 0
- name: Persist swap in fstab
lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: present
- name: Set swappiness to 10 (use swap only under pressure)
sysctl:
name: vm.swappiness
value: "10"
sysctl_set: true
state: present
reload: true
- name: Add deploy user to docker group
user:
name: "{{ ssh_user }}"
groups: docker
append: true
- name: Create app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0755"
roles:
- deploy
+40
View File
@@ -0,0 +1,40 @@
# Template for your Ansible Vault secrets file.
# Copy to vault.yml, fill in values, then encrypt:
# ansible-vault encrypt vault.yml
# Edit later with:
# ansible-vault edit vault.yml
# ── MQTT ─────────────────────────────────────────────────────────────────────
vault_mqtt_c2_user: drb-c2-core
vault_mqtt_c2_pass: "CHANGE_ME"
vault_mqtt_node_user: drb-node
vault_mqtt_node_pass: "CHANGE_ME"
# ── C2 Core ───────────────────────────────────────────────────────────────────
vault_service_key: "" # openssl rand -hex 32
vault_node_api_key: "" # openssl rand -hex 32
vault_openai_api_key: ""
vault_google_maps_api_key: ""
vault_gemini_api_key: ""
vault_gcs_bucket: "your-gcs-bucket-name"
vault_firestore_database: "c2-server"
# ── Gitea Container Registry ──────────────────────────────────────────────────
vault_registry_host: "git.vpn.cusano.net"
vault_registry_user: "logan"
vault_registry_token: "" # Gitea access token with package:write scope
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
# ── Discord Bot ───────────────────────────────────────────────────────────────
vault_discord_token: ""
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
vault_firebase_api_key: ""
vault_firebase_auth_domain: ""
vault_firebase_project_id: ""
vault_firebase_storage_bucket: ""
vault_firebase_messaging_sender_id: ""
vault_firebase_app_id: ""
# No GCP key needed — the VM uses Application Default Credentials via the
# GCE metadata server. Terraform grants the required IAM roles at apply time.
+189
View File
@@ -0,0 +1,189 @@
terraform {
required_version = ">= 1.6"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
# Store state in GCS — create the bucket manually once before first apply
# Uncomment once GCS bucket permissions are confirmed working.
# backend "gcs" {
# bucket = "drb-tf-state"
# prefix = "drb/state"
# }
}
provider "google" {
project = var.project_id
region = var.region
}
# Pull live project metadata (number, name) without hardcoding them.
data "google_project" "current" {}
# ---------------------------------------------------------------------------
# Static external IP
# ---------------------------------------------------------------------------
resource "google_compute_address" "drb" {
name = "drb-server-ip"
region = var.region
}
# ---------------------------------------------------------------------------
# Firewall rules
# ---------------------------------------------------------------------------
resource "google_compute_firewall" "allow_web" {
name = "drb-allow-web"
network = "default"
allow {
protocol = "tcp"
ports = ["80", "443"]
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["drb-server"]
}
resource "google_compute_firewall" "allow_ssh" {
name = "drb-allow-ssh"
network = "default"
allow {
protocol = "tcp"
ports = ["22"]
}
# Restrict SSH to your IP(s) and Gitea runner IP
source_ranges = var.allowed_ssh_cidrs
target_tags = ["drb-server"]
}
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
# If you need to temporarily allow direct MQTT access for testing, uncomment and
# restrict source_ranges to your node IPs.
#
# resource "google_compute_firewall" "allow_mqtt" {
# name = "drb-allow-mqtt"
# network = "default"
# allow {
# protocol = "tcp"
# ports = ["8883"] # TLS MQTT, not 1883
# }
# source_ranges = ["YOUR_NODE_CIDR"]
# target_tags = ["drb-server"]
# }
# ---------------------------------------------------------------------------
# Compute Engine VM
# ---------------------------------------------------------------------------
resource "google_compute_instance" "drb_server" {
name = "drb-server"
machine_type = var.machine_type
zone = var.zone
tags = ["drb-server"]
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
type = "pd-standard"
}
}
network_interface {
network = "default"
access_config {
nat_ip = google_compute_address.drb.address
}
}
metadata = {
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
}
# Startup script runs once on first boot to install Docker + Caddy
metadata_startup_script = file("${path.module}/startup.sh")
# The default compute service account with cloud-platform scope gives the VM
# full access to GCS and Firestore in the same project — no key file needed.
service_account {
scopes = ["cloud-platform"]
}
lifecycle {
# Prevent Terraform from destroying + recreating on metadata changes
ignore_changes = [metadata_startup_script]
}
}
# ---------------------------------------------------------------------------
# IAM — grant the VM's default compute SA access to Firestore and GCS.
# Since Firebase/GCS already live in the same project, no key file is needed —
# the VM authenticates via the metadata server (ADC).
# ---------------------------------------------------------------------------
locals {
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
}
resource "google_project_iam_member" "drb_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = local.compute_sa
}
resource "google_project_iam_member" "drb_gcs" {
project = var.project_id
role = "roles/storage.objectAdmin"
member = local.compute_sa
}
# ---------------------------------------------------------------------------
# Firestore database — import existing, manages schema/settings going forward
# ---------------------------------------------------------------------------
import {
id = "projects/${var.project_id}/databases/${var.firestore_database}"
to = google_firestore_database.c2
}
resource "google_firestore_database" "c2" {
project = var.project_id
name = var.firestore_database
location_id = var.firestore_location
type = "FIRESTORE_NATIVE"
# Prevent accidental deletion of the live database
deletion_policy = "DELETE"
}
# ---------------------------------------------------------------------------
# GCS bucket — audio recordings. Import existing bucket.
# ---------------------------------------------------------------------------
import {
id = var.audio_bucket_name
to = google_storage_bucket.audio
}
resource "google_storage_bucket" "audio" {
project = var.project_id
name = var.audio_bucket_name
location = var.audio_bucket_location
uniform_bucket_level_access = true
}
# ---------------------------------------------------------------------------
# DNS — managed in AWS Route 53 (cusano.net is there).
# After terraform apply, add these A records in Route 53:
# app.drb.cusano.net → server_ip output
# api.drb.cusano.net → server_ip output
# Or use a single wildcard: *.drb.cusano.net → server_ip
# ---------------------------------------------------------------------------
+22
View File
@@ -0,0 +1,22 @@
output "server_ip" {
value = google_compute_address.drb.address
description = "Static external IP of the DRB server VM"
}
output "app_url" {
value = "https://app.${var.domain}"
}
output "api_url" {
value = "https://api.${var.domain}"
}
output "project_number" {
value = data.google_project.current.number
description = "GCP project number (useful for service account references)"
}
output "ssh_command" {
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
description = "SSH command to reach the server (should rarely be needed)"
}
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy.
set -euxo pipefail
# ── Docker ────────────────────────────────────────────────────────────────────
apt-get update -y
apt-get install -y ca-certificates curl gnupg lsb-release
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl start docker
# Allow drb user to run docker
usermod -aG docker drb 2>/dev/null || true
# ── Caddy (reverse proxy + auto TLS) ─────────────────────────────────────────
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -y
apt-get install -y caddy
# ── App directory — clone repo so CI can git pull + docker compose up ─────────
apt-get install -y git
mkdir -p /opt/drb
# Repo is cloned here by initial setup; CI just git pulls and rebuilds.
# Set safe directory for the drb user
git config --global --add safe.directory /opt/drb
chown -R drb:drb /opt/drb 2>/dev/null || true
# ── Caddyfile placeholder (CI will write the real one on first deploy) ────────
cat > /etc/caddy/Caddyfile <<'CADDY'
# This file is managed by CI. Do not edit manually.
# It will be replaced on the first deployment.
:80 {
respond "DRB server — waiting for deployment" 200
}
CADDY
systemctl enable caddy
systemctl reload caddy
echo "Startup complete."
+24
View File
@@ -0,0 +1,24 @@
# Copy to terraform.tfvars and fill in values.
# terraform.tfvars is gitignored — never commit it.
project_id = "your-gcp-project-id" # gcloud config get-value project
region = "us-central1"
zone = "us-central1-a"
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
ssh_user = "drb"
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
# Your IP + any CI runner IPs that need SSH access
allowed_ssh_cidrs = ["YOUR_IP/32"]
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
audio_bucket_name = "your-audio-bucket-name"
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
# Existing Firestore database ID and location (imported into state)
firestore_database = "c2-server"
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
+66
View File
@@ -0,0 +1,66 @@
variable "project_id" {
description = "GCP project ID"
type = string
}
variable "region" {
description = "GCP region"
type = string
default = "us-central1"
}
variable "zone" {
description = "GCP zone"
type = string
default = "us-central1-a"
}
variable "domain" {
description = "Base domain (e.g. example.com)"
type = string
}
variable "machine_type" {
description = "Compute Engine machine type"
type = string
default = "e2-small"
}
variable "ssh_user" {
description = "SSH username for the VM"
type = string
default = "drb"
}
variable "ssh_public_key" {
description = "SSH public key to authorize on the VM"
type = string
}
variable "allowed_ssh_cidrs" {
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
type = list(string)
}
variable "audio_bucket_name" {
description = "Existing GCS bucket name for call audio recordings"
type = string
}
variable "audio_bucket_location" {
description = "GCS bucket location — must match the existing bucket's location exactly"
type = string
default = "US-CENTRAL1"
}
variable "firestore_database" {
description = "Firestore database ID (e.g. c2-server)"
type = string
default = "c2-server"
}
variable "firestore_location" {
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
type = string
default = "nam5"
}