diff --git a/drb-c2-core/app/internal/audit.py b/drb-c2-core/app/internal/audit.py new file mode 100644 index 0000000..c4e6387 --- /dev/null +++ b/drb-c2-core/app/internal/audit.py @@ -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) diff --git a/drb-c2-core/app/internal/auth.py b/drb-c2-core/app/internal/auth.py index 10d0cdb..c662759 100644 --- a/drb-c2-core/app/internal/auth.py +++ b/drb-c2-core/app/internal/auth.py @@ -37,12 +37,28 @@ async def require_service_or_firebase_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( credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), ) -> 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) - if not decoded.get("admin"): + if get_role(decoded) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return decoded @@ -77,7 +93,7 @@ async def require_service_key_or_admin( decoded = firebase_auth.verify_id_token(token) except Exception: raise HTTPException(status_code=401, detail="Invalid or expired token") - if not decoded.get("admin"): + if get_role(decoded) != "admin": raise HTTPException(status_code=403, detail="Admin access required") return decoded diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 42ffc59..07087f9 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop from app.internal.recorrelation_sweep import recorrelation_loop from app.config import settings 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, places +from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.internal import firestore as fstore @@ -72,6 +72,8 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi 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(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") diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 184e5ce..acbbf89 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -146,6 +146,10 @@ class TripCreate(BaseModel): maps_link: Optional[str] = None start_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): @@ -157,6 +161,19 @@ class TripEventCreate(BaseModel): maps_link: Optional[str] = None place_id: Optional[str] = None # Google Place ID 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): diff --git a/drb-c2-core/app/routers/admin.py b/drb-c2-core/app/routers/admin.py index 8ff8748..307d1f3 100644 --- a/drb-c2-core/app/routers/admin.py +++ b/drb-c2-core/app/routers/admin.py @@ -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 import firestore as fstore - 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.""" global_stt = global_flags.get("stt_enabled", True) @@ -163,3 +162,15 @@ async def debug_correlation( "incidents": incident_records, "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] diff --git a/drb-c2-core/app/routers/calls.py b/drb-c2-core/app/routers/calls.py index 1fb3c74..9e5ab39 100644 --- a/drb-c2-core/app/routers/calls.py +++ b/drb-c2-core/app/routers/calls.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone, timedelta from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends from pydantic import BaseModel 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} +@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") async def patch_transcript( call_id: str, diff --git a/drb-c2-core/app/routers/links.py b/drb-c2-core/app/routers/links.py new file mode 100644 index 0000000..d9340d4 --- /dev/null +++ b/drb-c2-core/app/routers/links.py @@ -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} diff --git a/drb-c2-core/app/routers/places.py b/drb-c2-core/app/routers/places.py index 2ce71db..80797cf 100644 --- a/drb-c2-core/app/routers/places.py +++ b/drb-c2-core/app/routers/places.py @@ -5,8 +5,9 @@ from app.internal.logger import logger router = APIRouter(prefix="/places", tags=["places"]) -PLACES_SEARCH_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json" -DIRECTIONS_URL = "https://maps.googleapis.com/maps/api/directions/json" +_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") @@ -17,9 +18,13 @@ async def search_places(query: str = Query(...), near: str = Query("")): full_query = f"{query} {near}".strip() try: async with httpx.AsyncClient(timeout=10) as client: - r = await client.get( - PLACES_SEARCH_URL, - params={"query": full_query, "key": settings.google_maps_api_key}, + 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() @@ -29,15 +34,15 @@ async def search_places(query: str = Query(...), near: str = Query("")): return [ { - "name": p.get("name"), - "address": p.get("formatted_address"), - "place_id": p.get("place_id"), - "lat": p.get("geometry", {}).get("location", {}).get("lat"), - "lng": p.get("geometry", {}).get("location", {}).get("lng"), - "maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}", + "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("results", [])[:6] + for p in data.get("places", [])[:6] ] @@ -51,13 +56,16 @@ async def get_directions( try: async with httpx.AsyncClient(timeout=10) as client: - r = await client.get( - DIRECTIONS_URL, - params={ - "origin": origin, - "destination": destination, - "mode": "driving", - "key": settings.google_maps_api_key, + 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() @@ -70,9 +78,23 @@ async def get_directions( if not routes: return {"duration_text": None, "duration_seconds": None, "distance_text": None} - leg = routes[0]["legs"][0] + 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": leg["duration"]["text"], - "duration_seconds": leg["duration"]["value"], - "distance_text": leg["distance"]["text"], + "duration_text": duration_text, + "duration_seconds": duration_seconds, + "distance_text": distance_text, } diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index 0b3e821..59787c5 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel -from app.models import TripCreate, TripEventCreate, AttendeeAction +from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction from app.internal import firestore as fstore from app.config import settings from app.internal.logger import logger @@ -18,6 +18,32 @@ from app.internal.auth import ( 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 # --------------------------------------------------------------------------- @@ -47,6 +73,26 @@ _TOOLS = [ }, }, }, + { + "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": { @@ -66,6 +112,7 @@ _TOOLS = [ "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"], }, @@ -74,25 +121,38 @@ _TOOLS = [ ] +_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.get( - "https://maps.googleapis.com/maps/api/place/textsearch/json", - params={"query": f"{query} {near}".strip(), "key": settings.google_maps_api_key}, + 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("name"), - "address": p.get("formatted_address"), - "place_id": p.get("place_id"), - "maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}", + "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 data.get("results", [])[:5] + for p in places[:5] ] except Exception as e: logger.error(f"Places search in assistant failed: {e}") @@ -120,20 +180,24 @@ def _build_system_prompt(trip: dict, events: list[dict]) -> str: 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} +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. @@ -151,8 +215,12 @@ class ChatRequest(BaseModel): @router.get("") -async def list_trips(): - return await fstore.collection_list("trips") +async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)): + 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("") @@ -169,6 +237,10 @@ async def create_trip(body: TripCreate): "start_date": body.start_date, "end_date": body.end_date, "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, } await fstore.doc_set("trips", trip_id, doc, merge=False) @@ -176,15 +248,32 @@ async def create_trip(body: TripCreate): @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) if not trip: 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.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} +@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}") async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) @@ -207,12 +296,49 @@ async def join_trip( trip = await fstore.doc_get("trips", trip_id) if not trip: 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[body.discord_user_id] = body.discord_username or body.discord_user_id await fstore.doc_update("trips", trip_id, {"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") async def leave_trip( trip_id: str, @@ -261,6 +387,7 @@ async def create_event(trip_id: str, body: TripEventCreate): "maps_link": body.maps_link, "place_id": body.place_id, "notes": body.notes, + "tags": body.tags, "attendees": {}, "created_at": now, } @@ -268,6 +395,45 @@ async def create_event(trip_id: str, body: TripEventCreate): 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}") async def delete_event( trip_id: str, @@ -389,7 +555,19 @@ async def trip_chat( for tc in msg.tool_calls: args = json.loads(tc.function.arguments) - if tc.function.name == "search_places": + 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] @@ -402,8 +580,10 @@ async def trip_chat( elif tc.function.name == "propose_event": suggestion = {k: args.get(k) for k in ( - "title", "date", "start_time", "end_time", "location", "maps_link", "notes" + "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", diff --git a/drb-c2-core/app/routers/users.py b/drb-c2-core/app/routers/users.py new file mode 100644 index 0000000..0cbcbcd --- /dev/null +++ b/drb-c2-core/app/routers/users.py @@ -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} diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index 9d10e1c..41464bc 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -2,8 +2,13 @@ import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; +import type { UserRecord, AuditEntry, UserRole } from "@/lib/types"; + +// --------------------------------------------------------------------------- +// Shared primitives +// --------------------------------------------------------------------------- interface FeatureFlags { 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 = { + 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 = { admin: "Admin", operator: "Operator", viewer: "Viewer" }; + return ( + + {labels[role]} + + ); +} + +// --------------------------------------------------------------------------- +// AI Features tab +// --------------------------------------------------------------------------- + +function FeaturesTab() { + const [flags, setFlags] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [error, setError] = useState(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 ( +
+ {error && ( +
+

{error}

+
+ )} + {loading ? ( +

Loading…

+ ) : ( +
+ {FLAG_META.map(({ key, label, description }) => ( +
+
+

{label}

+

{description}

+
+ handleToggle(key, val)} + disabled={saving === key} + /> +
+ ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Correlation Debug tab +// --------------------------------------------------------------------------- + function CorrelationDebugTab() { const [limit, setLimit] = useState(20); 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 }>; } | null; - // Aggregate corr_path and corr_fit_signal counts across all incident calls. const pathCounts: Record = {}; const signalCounts: Record = {}; if (meta?.incidents) { @@ -245,91 +342,762 @@ function CorrelationDebugTab() { ); } -export default function AdminPage() { - const { isAdmin } = useAuth(); - const router = useRouter(); - const [tab, setTab] = useState<"features" | "correlation">("features"); +// --------------------------------------------------------------------------- +// User detail panel +// --------------------------------------------------------------------------- - const [flags, setFlags] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(null); +function UserDetailPanel({ + user, + onClose, + onUpdated, + currentUid, +}: { + user: UserRecord; + onClose: () => void; + onUpdated: (u: UserRecord) => void; + currentUid: string; +}) { + const [detail, setDetail] = useState(user); + const [editRole, setEditRole] = useState(user.role); + const [editNodes, setEditNodes] = useState(user.owned_node_ids.join(", ")); + const [editName, setEditName] = useState(user.display_name ?? ""); + const [saving, setSaving] = useState(false); + const [toggling, setToggling] = useState(false); + const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); + const [showSessions, setShowSessions] = useState(false); + // Fetch full detail (sessions) lazily useEffect(() => { - if (!isAdmin) { - router.replace("/dashboard"); - return; - } - c2api.getFeatureFlags() - .then((f) => setFlags(f as unknown as FeatureFlags)) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }, [isAdmin, router]); + c2api.getUser(user.uid) + .then((d) => setDetail(d)) + .catch(() => {}); + }, [user.uid]); - async function handleToggle(key: keyof FeatureFlags, value: boolean) { - if (!flags) return; - setSaving(key); + async function handleSave() { + setSaving(true); setError(null); + const nodes = editRole === "operator" + ? editNodes.split(",").map((s) => s.trim()).filter(Boolean) + : []; try { - const updated = await c2api.setFeatureFlags({ [key]: value }); - setFlags(updated as unknown as FeatureFlags); + const updated = await c2api.updateUser(user.uid, { + role: editRole, + owned_node_ids: nodes, + display_name: editName || undefined, + }); + onUpdated(updated); + setDetail((d) => ({ ...d, ...updated })); } catch (e) { - setError(String(e)); + setError(e instanceof Error ? e.message : String(e)); } 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 ( -
-

Admin

- -
- {(["features", "correlation"] as const).map((t) => ( - - ))} +
+
+
+

{detail.email}

+

{detail.uid}

+
+
- {tab === "features" && ( -
- {error && ( -
-

{error}

-
- )} - {loading ? ( -

Loading…

- ) : ( -
- {FLAG_META.map(({ key, label, description }) => ( -
-
-

{label}

-

{description}

-
- handleToggle(key, val)} - disabled={saving === key} - /> + {error && ( +
+

{error}

+
+ )} + +
+
+ + setEditName(e.target.value)} + 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" + placeholder="Full name" + /> +
+ +
+ + +
+ + {editRole === "operator" && ( +
+ + 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" + /> +
+ )} + + +
+ +
+
+ Status + + {detail.disabled ? "Disabled" : "Active"} + +
+
+ Discord + + {detail.discord_linked + ? `@${detail.discord_username ?? detail.discord_user_id}` + : "Not linked"} + +
+
+ Created + {fmtDate(detail.creation_time)} +
+
+ Last sign-in + {fmtDate(detail.last_sign_in)} +
+
+ + {(detail.sessions?.length ?? 0) > 0 && ( +
+ + {showSessions && ( +
+ {detail.sessions?.map((s) => ( +
+ {fmtDatetime(s.timestamp)} + {s.ip ?? "—"}
))}
)} -
+
)} - {tab === "correlation" && } +
+ {!isSelf ? ( + <> + + + + ) : ( +

Cannot disable or delete your own account.

+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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("viewer"); + const [nodeIds, setNodeIds] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [inviteLink, setInviteLink] = useState(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 ( +
+
+

User Created

+

+ Share this one-time invite link with the new user so they can set their password. + It expires after use. +

+
+

{inviteLink}

+
+
+ + +
+
+
+ ); + } + + return ( +
+
+

Create User

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+ {role === "operator" && ( +
+ + 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" + /> +
+ )} + {error &&

{error}

} +
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Users tab +// --------------------------------------------------------------------------- + +function UsersTab({ currentUid }: { currentUid: string }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedUid, setSelectedUid] = useState(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 ( +
+ {showCreate && ( + setShowCreate(false)} + onCreated={(u) => { handleCreated(u); setShowCreate(false); }} + /> + )} + +
+

{users.length} user{users.length !== 1 ? "s" : ""}

+ +
+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +

Loading…

+ ) : users.length === 0 ? ( +

No users found.

+ ) : ( +
+ + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + +
EmailNameRoleDiscordLast sign-inStatus
{u.email ?? "—"}{u.display_name ?? "—"} + {u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"} + {fmtDate(u.last_sign_in)} + {u.disabled + ? Disabled + : Active + } + + +
+
+ )} + + {selectedUser && ( + setSelectedUid(null)} + onUpdated={handleUpdated} + currentUid={currentUid} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Audit Log tab +// --------------------------------------------------------------------------- + +function AuditLogTab() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(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 ( +
+ {error && ( +
+

{error}

+
+ )} + + {loading ? ( +

Loading…

+ ) : entries.length === 0 ? ( +

No audit entries yet.

+ ) : ( + <> +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
TimeActionActorTargetDetails
{fmtDatetime(e.timestamp)}{e.action}{e.actor_email}{e.target_email ?? "—"} + {Object.keys(e.details).length > 0 + ? Object.entries(e.details) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join(" · ") + : "—"} +
+
+ + {hasMore && ( + + )} + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( +
+

+ Finds calls stuck in active status because a node rebooted before sending an end-call event. + Preview first, then close. +

+ +
+
+ + 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" + /> +
+ + +
+ + {error && ( +
+

{error}

+
+ )} + + {result && ( +
+

+ {result.dry_run ? "Preview: " : "Closed: "} + 0 ? "text-amber-400" : "text-green-400"}> + {result.count} stale call{result.count !== 1 ? "s" : ""} + + {result.count === 0 && — nothing to clear} +

+ {result.call_ids.length > 0 && ( +
+ {result.call_ids.map((id) => ( +

{id}

+ ))} +
+ )} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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("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 ( +
+

Admin

+ +
+ {TAB_LABELS.map(({ key, label }) => ( + + ))} +
+ + {tab === "features" && } + {tab === "correlation" && } + {tab === "calls" && } + {tab === "users" && } + {tab === "audit" && }
); } diff --git a/drb-frontend/app/login/page.tsx b/drb-frontend/app/login/page.tsx index 938f199..88d72bc 100644 --- a/drb-frontend/app/login/page.tsx +++ b/drb-frontend/app/login/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { auth } from "@/lib/firebase"; +import { c2api } from "@/lib/c2api"; import { useRouter } from "next/navigation"; export default function LoginPage() { @@ -18,6 +19,7 @@ export default function LoginPage() { setError(null); try { await signInWithEmailAndPassword(auth, email, password); + c2api.recordSession().catch(() => {}); router.push("/dashboard"); } catch { setError("Invalid email or password."); @@ -31,6 +33,7 @@ export default function LoginPage() { setError(null); try { await signInWithPopup(auth, new GoogleAuthProvider()); + c2api.recordSession().catch(() => {}); router.push("/dashboard"); } catch { setError("Google sign-in failed. Try again."); diff --git a/drb-frontend/app/nodes/page.tsx b/drb-frontend/app/nodes/page.tsx index 7ddca18..9391b03 100644 --- a/drb-frontend/app/nodes/page.tsx +++ b/drb-frontend/app/nodes/page.tsx @@ -1,15 +1,25 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { useNodes } from "@/lib/useNodes"; import { useSystems } from "@/lib/useSystems"; import { NodeCard } from "@/components/NodeCard"; import { NodeConfigModal } from "@/components/NodeConfigModal"; +import { useAuth } from "@/components/AuthProvider"; import type { NodeRecord } from "@/lib/types"; export default function NodesPage() { + const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const router = useRouter(); const { nodes, loading } = useNodes(); 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(null); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); diff --git a/drb-frontend/app/profile/page.tsx b/drb-frontend/app/profile/page.tsx new file mode 100644 index 0000000..abc22b0 --- /dev/null +++ b/drb-frontend/app/profile/page.tsx @@ -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 ( +
+ {letters.toUpperCase()} +
+ ); +} + +export default function ProfilePage() { + const { user, isAdmin, role, signOut } = useAuth(); + const router = useRouter(); + + const [linkStatus, setLinkStatus] = useState(null); + const [linkLoading, setLinkLoading] = useState(true); + const [code, setCode] = useState(null); + const [codeExpiry, setCodeExpiry] = useState(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 ( +
+ {/* Header */} +
+ +
+

{displayName}

+ {user.displayName && user.email && ( +

{user.email}

+ )} + {role && ( + + {role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} + + )} +
+
+ + {/* Firebase account */} +
+
+

Account

+
+ + + + {user.metadata.creationTime && ( + + )} + {user.metadata.lastSignInTime && ( + + )} +
+
+
+ + {/* Discord linking */} +
+
+

Discord

+ + {linkLoading ? ( +

Loading…

+ ) : linkStatus?.linked ? ( +
+
+ {linkStatus.discord_username && ( + + )} + {linkStatus.discord_user_id && ( + + )} + {linkStatus.linked_at && ( + + )} +
+
+ +
+
+ ) : ( +
+

+ Link your Discord account to access private trips from both the web and Discord. +

+ {code ? ( +
+
+ {code} +
+

+ Run /link {code} in Discord. Code expires in {codeExpiry} minutes. +

+ +
+ ) : ( + + )} +
+ )} +
+
+ + {/* Sign out */} +
+
+

Sign out of this device

+ +
+
+
+ ); +} + +function Row({ label, value, mono = false, truncate = false }: { + label: string; + value: string; + mono?: boolean; + truncate?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index 5dfe759..1d2758c 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -1,8 +1,10 @@ "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 { c2api } from "@/lib/c2api"; +import { useAuth } from "@/components/AuthProvider"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; // ── P25 structured config types ─────────────────────────────────────────────── @@ -1178,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) { // ── Systems list page ───────────────────────────────────────────────────────── export default function SystemsPage() { + const { isAdmin, isOperator, loading: authLoading } = useAuth(); + const router = useRouter(); 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(null); const [editIsDuplicate, setEditIsDuplicate] = useState(false); diff --git a/drb-frontend/app/tokens/page.tsx b/drb-frontend/app/tokens/page.tsx index 233e8c0..f016d94 100644 --- a/drb-frontend/app/tokens/page.tsx +++ b/drb-frontend/app/tokens/page.tsx @@ -15,7 +15,7 @@ interface TokenRecord { } export default function TokensPage() { - const { isAdmin, loading: authLoading } = useAuth(); + const { isAdmin, isOperator, loading: authLoading } = useAuth(); const router = useRouter(); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); @@ -26,8 +26,8 @@ export default function TokensPage() { const [error, setError] = useState(null); useEffect(() => { - if (!authLoading && !isAdmin) router.replace("/dashboard"); - }, [authLoading, isAdmin, router]); + if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard"); + }, [authLoading, isAdmin, isOperator, router]); const refresh = useCallback(async () => { try { @@ -67,7 +67,7 @@ export default function TokensPage() { } } - if (authLoading || !isAdmin) return null; + if (authLoading || (!isAdmin && !isOperator)) return null; return (
diff --git a/drb-frontend/app/trips/[id]/page.tsx b/drb-frontend/app/trips/[id]/page.tsx index 975d1bc..47d9d76 100644 --- a/drb-frontend/app/trips/[id]/page.tsx +++ b/drb-frontend/app/trips/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; @@ -10,6 +11,10 @@ import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types"; // Helpers // --------------------------------------------------------------------------- +function uid(): string { + try { return crypto.randomUUID(); } catch { return Math.random().toString(36).slice(2) + Date.now().toString(36); } +} + function toMin(t: string): number { const [h, m] = t.split(":").map(Number); return h * 60 + (m ?? 0); @@ -53,11 +58,42 @@ function dateRange(start: string, end: string): string[] { return dates; } -function detectConflicts(events: TripEvent[]): Set { +// --------------------------------------------------------------------------- +// Tag helpers +// --------------------------------------------------------------------------- + +const TAG_PALETTE = [ + "bg-violet-900/60 text-violet-300 border-violet-700/50", + "bg-sky-900/60 text-sky-300 border-sky-700/50", + "bg-emerald-900/60 text-emerald-300 border-emerald-700/50", + "bg-amber-900/60 text-amber-300 border-amber-700/50", + "bg-rose-900/60 text-rose-300 border-rose-700/50", + "bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50", + "bg-cyan-900/60 text-cyan-300 border-cyan-700/50", + "bg-orange-900/60 text-orange-300 border-orange-700/50", +]; + +function tagColor(tag: string, availableTags: string[]): string { + const idx = availableTags.indexOf(tag); + return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length]; +} + +function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) { + return ( + + {tag} + + ); +} + +function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set { const timed = events.filter((e) => e.start_time); const conflicts = new Set(); for (let i = 0; i < timed.length; i++) { for (let j = i + 1; j < timed.length; j++) { + const aExempt = timed[i].tags?.some((t) => overlapTags.includes(t)); + const bExempt = timed[j].tags?.some((t) => overlapTags.includes(t)); + if (aExempt || bExempt) continue; const aS = toMin(timed[i].start_time!); const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60; const bS = toMin(timed[j].start_time!); @@ -147,12 +183,16 @@ function AddEventModal({ onClose, onAdd, prefill, + editEventId, }: { trip: TripRecord; onClose: () => void; onAdd: (body: object) => Promise; prefill?: Partial; + editEventId?: string; }) { + const isEdit = !!editEventId; + const availableTags = trip.available_tags ?? []; const [title, setTitle] = useState(prefill?.title ?? ""); const [date, setDate] = useState(prefill?.date ?? trip.start_date); const [startTime, setStartTime] = useState(prefill?.start_time ?? ""); @@ -161,6 +201,7 @@ function AddEventModal({ const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? ""); const [placeId, setPlaceId] = useState(prefill?.place_id ?? ""); const [notes, setNotes] = useState(prefill?.notes ?? ""); + const [tags, setTags] = useState(prefill?.tags ?? []); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -185,10 +226,11 @@ function AddEventModal({ maps_link: mapsLink || null, place_id: placeId || null, notes: notes || null, + tags, }); onClose(); } catch { - setError("Failed to add event. Check the date is within the trip range."); + setError(isEdit ? "Failed to save changes." : "Failed to add event. Check the date is within the trip range."); } finally { setSaving(false); } @@ -200,7 +242,7 @@ function AddEventModal({ onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto" > -

Add Event

+

{isEdit ? "Edit Event" : "Add Event"}

@@ -272,6 +314,29 @@ function AddEventModal({ />
+ {availableTags.length > 0 && ( +
+ +
+ {availableTags.map((tag) => { + const active = tags.includes(tag); + return ( + + ); + })} +
+
+ )} + {error &&

{error}

}
@@ -282,7 +347,7 @@ function AddEventModal({ 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 ? "Adding…" : "Add Event"} + {saving ? (isEdit ? "Saving…" : "Adding…") : (isEdit ? "Save Changes" : "Add Event")}
@@ -300,18 +365,27 @@ function DayTimeline({ events, isAdmin, onDelete, + onEdit, driveSegments, + availableTags, + overlapTags, }: { events: TripEvent[]; isAdmin: boolean; onDelete: (id: string) => void; + onEdit: (event: TripEvent) => void; driveSegments: { fromId: string; toId: string; text: string }[]; + availableTags: string[]; + overlapTags: string[]; }) { const timed = [...events.filter((e) => e.start_time)].sort( (a, b) => toMin(a.start_time!) - toMin(b.start_time!) ); const untimed = events.filter((e) => !e.start_time); - const conflicts = detectConflicts(events); + const isNote = (e: TripEvent) => !!e.tags?.some((t) => overlapTags.includes(t)); + const noteEvents = timed.filter((e) => isNote(e)); + const regularEvents = timed.filter((e) => !isNote(e)); + const conflicts = detectConflicts(events, overlapTags); if (timed.length === 0 && untimed.length === 0) { return ( @@ -357,8 +431,45 @@ function DayTimeline({
))} - {/* Event blocks */} - {timed.map((e) => { + {/* Note events — overlap-allowed, rendered behind as subtle bands */} + {noteEvents.map((e) => { + const startMin = toMin(e.start_time!); + const endMin = e.end_time ? toMin(e.end_time) : startMin; + const top = (startMin - rangeStart) * PX_PER_MIN; + const height = Math.max(1, (endMin - startMin) * PX_PER_MIN); + + return ( +
+ {/* Shaded band (only if duration given) */} + {e.end_time && ( +
+ )} + {/* Dashed marker line + label */} +
+
+ + {fmtTime(e.start_time)} + + {e.title} + {isAdmin && ( +
+ + +
+ )} +
+
+ ); + })} + + {/* Regular event blocks */} + {regularEvents.map((e) => { const startMin = toMin(e.start_time!); const endMin = e.end_time ? toMin(e.end_time) : startMin + 60; const top = (startMin - rangeStart) * PX_PER_MIN; @@ -370,7 +481,7 @@ function DayTimeline({
= 60 && e.notes && (

{e.notes}

)} + {e.tags?.length > 0 && ( +
+ {e.tags.map((t) => )} +
+ )}
{e.maps_link && ( @@ -406,21 +522,28 @@ function DayTimeline({ )} {isAdmin && ( - + <> + + + )}
- {/* Drive time badge below this event, if present */} {drive && (
{drive} drive
@@ -446,6 +569,11 @@ function DayTimeline({

{e.location}

)} {e.notes &&

{e.notes}

} + {e.tags?.length > 0 && ( +
+ {e.tags.map((t) => )} +
+ )}
{e.maps_link && ( @@ -455,10 +583,16 @@ function DayTimeline({ )} {isAdmin && ( - + <> + + + )}
@@ -488,10 +622,13 @@ interface SuggestionCard { location?: string; maps_link?: string; notes?: string; + tags?: string[]; dismissed?: boolean; added?: boolean; } +const CHAT_STORAGE_KEY = (tripId: string) => `drb-trip-chat-${tripId}`; + function AssistantPanel({ trip, onAddEvent, @@ -499,21 +636,32 @@ function AssistantPanel({ trip: TripRecord & { events: TripEvent[] }; onAddEvent: (event: TripEvent) => void; }) { - const [messages, setMessages] = useState([]); + const storageKey = CHAT_STORAGE_KEY(trip.trip_id); + const [messages, setMessages] = useState(() => { + try { + const saved = localStorage.getItem(storageKey); + return saved ? JSON.parse(saved) : []; + } catch { return []; } + }); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); - const bottomRef = useRef(null); + const bottomRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); + useEffect(() => { + try { localStorage.setItem(storageKey, JSON.stringify(messages)); } catch { /* quota */ } + }, [messages, storageKey]); + async function send() { const text = input.trim(); if (!text || loading) return; setInput(""); - const userMsg: ChatMessage = { id: crypto.randomUUID(), role: "user", content: text }; + const userMsg: ChatMessage = { id: uid(), role: "user", content: text }; setMessages((prev) => [...prev, userMsg]); setLoading(true); @@ -522,7 +670,7 @@ function AssistantPanel({ try { const res = await c2api.tripChat(trip.trip_id, text, history); const assistantMsg: ChatMessage = { - id: crypto.randomUUID(), + id: uid(), role: "assistant", content: res.reply, suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [], @@ -531,10 +679,11 @@ function AssistantPanel({ } catch { setMessages((prev) => [ ...prev, - { id: crypto.randomUUID(), role: "assistant", content: "Something went wrong. Try again." }, + { id: uid(), role: "assistant", content: "Something went wrong. Try again." }, ]); } finally { setLoading(false); + inputRef.current?.focus(); } } @@ -548,6 +697,7 @@ function AssistantPanel({ location: s.location ?? null, maps_link: s.maps_link ?? null, notes: s.notes ?? null, + tags: s.tags ?? [], }); onAddEvent(event); setMessages((prev) => @@ -570,14 +720,29 @@ function AssistantPanel({ ); } + function clearChat() { + setMessages([]); + try { localStorage.removeItem(storageKey); } catch { /* ignore */ } + } + return (
{/* Header */} -
-

Trip Assistant

-

- Tell me what you want to do — I can search places and suggest events. -

+
+
+

Trip Assistant

+

+ Tell me what you want to do — I can search places and suggest events. +

+
+ {messages.length > 0 && ( + + )}
{/* Messages */} @@ -610,7 +775,24 @@ function AssistantPanel({ : "bg-gray-800 text-gray-200" }`} > - {msg.content} + {msg.role === "user" ? msg.content : ( +

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {msg.content} +
    + )}
    {/* Suggestion cards */} @@ -636,6 +818,11 @@ function AssistantPanel({ )} {s.location &&

    {s.location}

    } {s.notes &&

    {s.notes}

    } + {s.tags && s.tags.length > 0 && ( +
    + {s.tags.map((t) => )} +
    + )}
    {s.maps_link && (
    - setInput(e.target.value)} + onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; }} onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())} placeholder="What do you want to do?" disabled={loading} - className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50" + rows={1} + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden" />
    -
    +
    + {/* Visibility badge */} + {trip.visibility === "private" && ( + + 🔒 private + + )} {isAdmin && ( <> +
    + + {/* Tag manager */} + {(isAdmin || (trip.available_tags ?? []).length > 0) && ( +
    + {(trip.available_tags ?? []).map((tag) => { + const isOverlap = (trip.overlap_tags ?? []).includes(tag); + return ( + + {tag} + {isAdmin && ( + <> + + + + )} + + ); + })} + {isAdmin && ( +
    + setTagInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())} + placeholder="Add tag…" + className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24" + /> + +
    + )} +
    + )} + + {/* Invite management — admin only, only when private */} + {isAdmin && trip.visibility === "private" && ( +
    +

    Invited

    +
    + {(trip.invited_discord_ids ?? []).length === 0 && ( + No invites yet + )} + {(trip.invited_discord_ids ?? []).map((discordId) => ( + + {discordId} + + + ))} +
    + setInviteInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())} + placeholder="Discord user ID…" + className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36" + /> + +
    +
    +
    + )}
    {/* Two-column layout */} @@ -907,7 +1245,10 @@ export default function TripDetailPage() { events={dayEvents} isAdmin={isAdmin} onDelete={handleDeleteEvent} + onEdit={setEditEvent} driveSegments={driveTimes[selectedDay] ?? []} + availableTags={trip.available_tags ?? []} + overlapTags={trip.overlap_tags ?? []} /> @@ -927,6 +1268,17 @@ export default function TripDetailPage() { prefill={selectedDay ? { date: selectedDay } : undefined} /> )} + + {/* Edit event modal */} + {editEvent && ( + setEditEvent(null)} + onAdd={handleUpdateEvent} + prefill={editEvent} + editEventId={editEvent.event_id} + /> + )} ); } diff --git a/drb-frontend/components/AuthProvider.tsx b/drb-frontend/components/AuthProvider.tsx index 885c718..0696013 100644 --- a/drb-frontend/components/AuthProvider.tsx +++ b/drb-frontend/components/AuthProvider.tsx @@ -3,25 +3,33 @@ import { createContext, useContext, useEffect, useState } from "react"; import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth"; import { auth } from "@/lib/firebase"; +import type { UserRole } from "@/lib/types"; interface AuthContextType { user: User | null; loading: boolean; + role: UserRole | null; isAdmin: boolean; + isOperator: boolean; + ownedNodeIds: string[]; signOut: () => Promise; } const AuthContext = createContext({ user: null, loading: true, + role: null, isAdmin: false, + isOperator: false, + ownedNodeIds: [], signOut: async () => {}, }); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); + const [role, setRole] = useState(null); + const [ownedNodeIds, setOwnedNodeIds] = useState([]); useEffect(() => { return onAuthStateChanged(auth, async (u) => { @@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (u) { document.cookie = "drb_session=1; path=/; SameSite=Strict"; - // Read custom claims to determine admin status 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 { 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"; } + const isAdmin = role === "admin"; + const isOperator = role === "operator"; + return ( - + {children} ); diff --git a/drb-frontend/components/Nav.tsx b/drb-frontend/components/Nav.tsx index 9632068..08bc08c 100644 --- a/drb-frontend/components/Nav.tsx +++ b/drb-frontend/components/Nav.tsx @@ -2,26 +2,32 @@ import { useState } from "react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useUnconfiguredNodes } from "@/lib/useNodes"; import { useUnacknowledgedAlerts } from "@/lib/useAlerts"; import { useAuth } from "@/components/AuthProvider"; import { useTheme } from "@/components/ThemeProvider"; -const links = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/nodes", label: "Nodes" }, - { href: "/systems", label: "Systems" }, - { href: "/calls", label: "Calls" }, - { href: "/incidents", label: "Incidents" }, - { href: "/map", label: "Map" }, - { href: "/alerts", label: "Alerts" }, - { href: "/trips", label: "Trips" }, +// Links visible to all authenticated roles (viewer+) +const viewerLinks = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/calls", label: "Calls" }, + { href: "/incidents", label: "Incidents" }, + { href: "/map", label: "Map" }, + { 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 = [ - { href: "/tokens", label: "Tokens" }, - { href: "/admin", label: "Admin" }, + { href: "/admin", label: "Admin" }, ]; function SunIcon() { @@ -49,8 +55,9 @@ function MoonIcon() { } export function Nav() { - const { user, isAdmin, signOut } = useAuth(); + const { user, isAdmin, isOperator } = useAuth(); const pathname = usePathname(); + const router = useRouter(); const { nodes: pending } = useUnconfiguredNodes(); const unackedAlerts = useUnacknowledgedAlerts(); const { theme, toggle } = useTheme(); @@ -58,7 +65,11 @@ export function Nav() { if (!user) return null; - const allLinks = [...links, ...(isAdmin ? adminLinks : [])]; + const allLinks = [ + ...viewerLinks, + ...(isAdmin || isOperator ? operatorLinks : []), + ...(isAdmin ? adminLinks : []), + ]; function navLinkClass(href: string) { return `text-sm font-mono transition-colors shrink-0 ${ @@ -101,12 +112,17 @@ export function Nav() { {theme === "dark" ? : } - {/* Sign out (desktop) */} + {/* Profile avatar (desktop) */} {/* Hamburger (mobile) */} @@ -154,12 +170,15 @@ export function Nav() { ))}
    - + Profile +
    )} diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index d675c08..0aea23d 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -63,6 +63,8 @@ export const c2api = { }, patchTranscript: (callId: string, transcript: string) => 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 getIncidents: (params?: { status?: string; type?: string }) => { @@ -142,8 +144,24 @@ export const c2api = { request("/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(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), + updateTripEvent: (tripId: string, eventId: string, body: object) => + request(`/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 }[]) => @@ -168,4 +186,34 @@ export const c2api = { method: "PUT", body: JSON.stringify(flags), }), + + // User management (admin only) + listUsers: () => + request("/admin/users"), + createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) => + request("/admin/users", { + method: "POST", + body: JSON.stringify(body), + }), + getUser: (uid: string) => + request(`/admin/users/${uid}`), + updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) => + request(`/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(`/admin/audit?limit=${limit}&offset=${offset}`), + + // Session recording — called on each explicit sign-in + recordSession: () => + request<{ ok: boolean }>("/auth/session", { method: "POST" }), }; diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 5f5bfed..bc17a9c 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -1,5 +1,44 @@ export type NodeStatus = "online" | "offline" | "recording" | "unconfigured"; 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; + timestamp: string; +} export interface NodeRecord { node_id: string; @@ -110,6 +149,7 @@ export interface TripEvent { maps_link: string | null; place_id: string | null; notes: string | null; + tags: string[]; attendees: Record; created_at: string; } @@ -132,6 +172,10 @@ export interface TripRecord { start_date: string; end_date: string; attendees: Record; + available_tags: string[]; + overlap_tags: string[]; + visibility: "public" | "private"; + invited_discord_ids: string[]; created_at: string; events?: TripEvent[]; } diff --git a/drb-frontend/package.json b/drb-frontend/package.json index 24bdca5..546632c 100644 --- a/drb-frontend/package.json +++ b/drb-frontend/package.json @@ -14,7 +14,8 @@ "react-dom": "^18.3.0", "firebase": "^10.12.0", "leaflet": "^1.9.4", -"react-leaflet": "^4.2.1" +"react-leaflet": "^4.2.1", + "react-markdown": "^9.0.1" }, "devDependencies": { "typescript": "^5.4.0", diff --git a/drb-server-discord-bot/app/commands/trips.py b/drb-server-discord-bot/app/commands/trips.py index 169bf5a..0797eb3 100644 --- a/drb-server-discord-bot/app/commands/trips.py +++ b/drb-server-discord-bot/app/commands/trips.py @@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str): # 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): def __init__(self, bot: commands.Bot): self.bot = bot @@ -79,10 +89,11 @@ class TripCommands(commands.Cog): self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: trips = await c2.get_trips() + user_id = str(interaction.user.id) return [ app_commands.Choice(name=t["name"], value=t["trip_id"]) 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] async def event_autocomplete( @@ -172,6 +183,9 @@ class TripCommands(commands.Cog): today = date.today().strftime("%Y-%m-%d") 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) for t in trips[:10]: upcoming = t.get("start_date", "") >= today @@ -181,8 +195,9 @@ class TripCommands(commands.Cog): f"{_fmt_date(t.get('end_date', ''))}" ) attendee_count = len(t.get("attendees", {})) + field_name = f"{t['name']} [{status}]"[:256] embed.add_field( - name=f"{t['name']} [{status}]", + name=field_name, value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going", inline=False, ) @@ -203,6 +218,9 @@ class TripCommands(commands.Cog): if not data: await interaction.followup.send("Trip not found.") 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()) desc_lines = [ @@ -215,8 +233,8 @@ class TripCommands(commands.Cog): ) embed = discord.Embed( - title=data["name"], - description="\n".join(desc_lines), + title=data["name"][:256], + description="\n".join(desc_lines)[:4096], color=0x5865f2, ) @@ -225,19 +243,21 @@ class TripCommands(commands.Cog): for e in data.get("events", []): 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 for day_iso in _date_range(data["start_date"], data["end_date"]): day_events = events_by_date.get(day_iso) if not day_events: continue - if field_count >= 24: + if field_count >= 24 or embed_chars >= 5800: embed.add_field(name="...", value="More events not shown.", inline=False) break day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d") lines = [] - for e in sorted(day_events, key=lambda x: x.get("time") or ""): - time_str = _fmt_time(e.get("time")) + for e in sorted(day_events, key=lambda x: x.get("start_time") or ""): + time_str = _fmt_time(e.get("start_time")) line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}" loc = e.get("location") @@ -248,13 +268,22 @@ class TripCommands(commands.Cog): if e.get("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()) if event_att: line += f"\n\u3000\u3000{', '.join(event_att)}" 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 if not events_by_date: @@ -290,9 +319,11 @@ class TripCommands(commands.Cog): @app_commands.autocomplete(trip=trip_autocomplete) async def trip_join(self, interaction: discord.Interaction, trip: str): await interaction.response.defer(ephemeral=True) - ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) - if ok: + result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name) + if result is True: 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: await interaction.followup.send("Failed to join trip.") @@ -421,5 +452,64 @@ class TripCommands(commands.Cog): 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): await bot.add_cog(TripCommands(bot)) diff --git a/drb-server-discord-bot/app/internal/c2_client.py b/drb-server-discord-bot/app/internal/c2_client.py index 1e8eea0..f63ed38 100644 --- a/drb-server-discord-bot/app/internal/c2_client.py +++ b/drb-server-discord-bot/app/internal/c2_client.py @@ -112,7 +112,55 @@ class C2Client: logger.error(f"C2 delete_trip failed: {e}") 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: async with httpx.AsyncClient(timeout=10) as client: r = await client.post( @@ -120,6 +168,8 @@ class C2Client: json={"discord_user_id": user_id, "discord_username": username}, headers=self._headers(), ) + if r.status_code == 403: + return "private" r.raise_for_status() return True except Exception as e: