Merge remote-tracking branch 'origin/main' into build-infrastructure

This commit is contained in:
Logan
2026-06-22 02:34:26 -04:00
24 changed files with 2611 additions and 191 deletions
+26
View File
@@ -0,0 +1,26 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from app.internal import firestore as fstore
async def write_audit(
actor_uid: str,
actor_email: str,
action: str,
target_uid: Optional[str] = None,
target_email: Optional[str] = None,
details: Optional[dict] = None,
) -> None:
"""Write an entry to the audit_log collection."""
doc_id = str(uuid4())
await fstore.doc_set("audit_log", doc_id, {
"log_id": doc_id,
"action": action,
"actor_uid": actor_uid,
"actor_email": actor_email,
"target_uid": target_uid,
"target_email": target_email,
"details": details or {},
"timestamp": datetime.now(timezone.utc).isoformat(),
}, merge=False)
+19 -3
View File
@@ -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
+3 -1
View File
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
from app.internal.recorrelation_sweep import recorrelation_loop
from app.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")
+17
View File
@@ -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):
+12 -1
View File
@@ -5,7 +5,6 @@ from app.internal.auth import require_admin_token, require_firebase_token
from app.internal.feature_flags import get_flags, set_flags
from app.internal 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]
+45
View File
@@ -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,
+152
View File
@@ -0,0 +1,152 @@
import random
import string
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from app.internal import firestore as fstore
from app.internal.auth import require_firebase_token, require_service_key
from app.internal.logger import logger
router = APIRouter(prefix="/auth", tags=["auth"])
_CODE_TTL_MINUTES = 15
def _gen_code() -> str:
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
# ---------------------------------------------------------------------------
# Web: generate a short-lived linking code
# ---------------------------------------------------------------------------
@router.post("/link/generate")
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
"""Authenticated Firebase user generates a code to paste into Discord /link."""
firebase_uid = decoded["uid"]
# Check if already linked
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
if existing and existing.get("discord_user_id"):
return {
"already_linked": True,
"discord_user_id": existing["discord_user_id"],
}
code = _gen_code()
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
await fstore.doc_set("link_codes", code, {
"firebase_uid": firebase_uid,
"expires_at": expires_at,
}, merge=False)
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
# ---------------------------------------------------------------------------
# Discord bot: resolve a code and store the link
# ---------------------------------------------------------------------------
class LinkResolveBody(BaseModel):
code: str
discord_user_id: str
discord_username: str = ""
@router.post("/link")
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
"""Discord bot resolves a linking code and permanently links the accounts."""
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
if not doc:
raise HTTPException(404, "Invalid or expired code.")
expires_at = datetime.fromisoformat(doc["expires_at"])
if datetime.now(timezone.utc) > expires_at:
await fstore.doc_delete("link_codes", body.code)
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
firebase_uid = doc["firebase_uid"]
# Check if this Discord account is already linked to a different Firebase UID
existing = await fstore.doc_get("discord_links", body.discord_user_id)
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
raise HTTPException(409, "This Discord account is already linked to a different account.")
now = datetime.now(timezone.utc).isoformat()
# Store both directions
await fstore.doc_set("discord_links", body.discord_user_id, {
"firebase_uid": firebase_uid,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
await fstore.doc_set("firebase_discord_links", firebase_uid, {
"discord_user_id": body.discord_user_id,
"discord_username": body.discord_username,
"linked_at": now,
}, merge=False)
# Clean up the code
await fstore.doc_delete("link_codes", body.code)
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
return {"ok": True, "firebase_uid": firebase_uid}
# ---------------------------------------------------------------------------
# Web: check current link status
# ---------------------------------------------------------------------------
@router.get("/link/status")
async def link_status(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if link and link.get("discord_user_id"):
return {
"linked": True,
"discord_user_id": link["discord_user_id"],
"discord_username": link.get("discord_username", ""),
"linked_at": link.get("linked_at"),
}
return {"linked": False}
# ---------------------------------------------------------------------------
# Web: unlink
# ---------------------------------------------------------------------------
@router.delete("/link")
async def unlink(decoded: dict = Depends(require_firebase_token)):
firebase_uid = decoded["uid"]
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
if not link or not link.get("discord_user_id"):
raise HTTPException(404, "No linked Discord account.")
discord_user_id = link["discord_user_id"]
await fstore.doc_delete("discord_links", discord_user_id)
await fstore.doc_delete("firebase_discord_links", firebase_uid)
return {"ok": True}
# ---------------------------------------------------------------------------
# Session recording — called by the frontend on each successful sign-in
# ---------------------------------------------------------------------------
@router.post("/session")
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
"""Record a sign-in event for the authenticated user."""
session_id = str(uuid4())
ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
await fstore.doc_set("user_sessions", session_id, {
"session_id": session_id,
"uid": decoded["uid"],
"email": decoded.get("email", ""),
"timestamp": datetime.now(timezone.utc).isoformat(),
"ip": ip,
"user_agent": user_agent,
}, merge=False)
return {"ok": True}
+45 -23
View File
@@ -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,
}
+196 -16
View File
@@ -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",
+308
View File
@@ -0,0 +1,308 @@
import asyncio
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from firebase_admin import auth as firebase_auth
from app.internal.auth import require_admin_token
from app.internal import firestore as fstore
from app.internal import audit
router = APIRouter(prefix="/admin/users", tags=["users"])
VALID_ROLES = {"admin", "operator", "viewer"}
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class UserCreate(BaseModel):
email: str
role: str = "viewer"
display_name: Optional[str] = None
owned_node_ids: list[str] = []
class UserUpdate(BaseModel):
role: Optional[str] = None
owned_node_ids: Optional[list[str]] = None
display_name: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
if ms is None:
return None
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]:
claims = fb_user.custom_claims or {}
if claims.get("role") == "admin" or claims.get("admin"):
role = "admin"
else:
role = claims.get("role", "viewer")
if role not in VALID_ROLES:
role = "viewer"
owned_node_ids = claims.get("owned_node_ids") or []
return role, owned_node_ids
def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict:
role, owned_node_ids = _extract_role_nodes(fb_user)
return {
"uid": fb_user.uid,
"email": fb_user.email,
"display_name": fb_user.display_name,
"role": role,
"owned_node_ids": owned_node_ids,
"disabled": fb_user.disabled,
"creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp),
"last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp),
"discord_linked": bool(link and link.get("discord_user_id")),
"discord_username": link.get("discord_username") if link else None,
"discord_user_id": link.get("discord_user_id") if link else None,
}
def _list_fb_users() -> list[firebase_auth.UserRecord]:
users: list[firebase_auth.UserRecord] = []
page = firebase_auth.list_users()
while page:
users.extend(page.users)
page = page.get_next_page()
return users
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("")
async def list_users(decoded: dict = Depends(require_admin_token)):
"""List all Firebase Auth users with role, node ownership, and Discord link status."""
fb_users = await asyncio.to_thread(_list_fb_users)
links: list[Optional[dict]] = await asyncio.gather(*[
fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users
])
return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)]
@router.post("")
async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)):
"""Create a new Firebase Auth user and set their role. Returns a one-time invite link."""
if body.role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if body.role == "operator" and not body.owned_node_ids:
raise HTTPException(400, "Operator role requires at least one owned node.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
firebase_auth.create_user,
email=body.email,
display_name=body.display_name or "",
email_verified=False,
)
except firebase_auth.EmailAlreadyExistsError:
raise HTTPException(409, "A user with this email already exists.")
except Exception as e:
raise HTTPException(400, f"Failed to create user: {e}")
# Set custom claims
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
if body.role == "admin":
claims["admin"] = True
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
# Write Firestore profile
now = datetime.now(timezone.utc).isoformat()
await fstore.doc_set("user_profiles", fb_user.uid, {
"uid": fb_user.uid,
"email": body.email,
"display_name": body.display_name or "",
"role": body.role,
"owned_node_ids": body.owned_node_ids,
"created_by_uid": decoded["uid"],
"created_at": now,
}, merge=False)
# Generate a one-time invite/password-reset link
invite_link: Optional[str] = None
try:
invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email)
except Exception:
pass
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.create",
target_uid=fb_user.uid,
target_email=body.email,
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
)
return {**_format_user(fb_user), "invite_link": invite_link}
@router.get("/{uid}")
async def get_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Get a single user with full detail, including recent sessions."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
link, raw_sessions = await asyncio.gather(
fstore.doc_get("firebase_discord_links", uid),
fstore.collection_where("user_sessions", [("uid", "==", uid)]),
)
raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
return {
**_format_user(fb_user, link),
"sessions": raw_sessions[:20],
}
@router.patch("/{uid}")
async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)):
"""Update a user's role, owned nodes, or display name."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
current_role, current_nodes = _extract_role_nodes(fb_user)
new_role = body.role if body.role is not None else current_role
new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes
if new_role not in VALID_ROLES:
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
if new_role == "operator" and not new_nodes:
raise HTTPException(400, "Operator role requires at least one owned node.")
# Merge with existing claims (preserve any other claims already set)
existing_claims: dict = dict(fb_user.custom_claims or {})
new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes}
if new_role == "admin":
new_claims["admin"] = True
else:
new_claims.pop("admin", None)
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
if body.display_name is not None:
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes}
if body.display_name is not None:
profile_data["display_name"] = body.display_name
await fstore.doc_set("user_profiles", uid, profile_data, merge=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.update",
target_uid=uid,
target_email=fb_user.email,
details={
"old_role": current_role,
"new_role": new_role,
"old_nodes": current_nodes,
"new_nodes": new_nodes,
},
)
updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
link = await fstore.doc_get("firebase_discord_links", uid)
return _format_user(updated, link)
@router.post("/{uid}/disable")
async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Disable a user — they can no longer sign in but their data is preserved."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot disable your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.disable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.post("/{uid}/enable")
async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Re-enable a previously disabled user."""
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.enable",
target_uid=uid,
target_email=fb_user.email,
)
return {"ok": True}
@router.delete("/{uid}")
async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)):
"""Permanently delete a user from Firebase Auth and clean up Firestore data."""
if uid == decoded.get("uid"):
raise HTTPException(400, "Cannot delete your own account.")
try:
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
except firebase_auth.UserNotFoundError:
raise HTTPException(404, "User not found.")
email = fb_user.email
# Clean up Discord link if present
link = await fstore.doc_get("firebase_discord_links", uid)
if link and link.get("discord_user_id"):
await asyncio.gather(
fstore.doc_delete("discord_links", link["discord_user_id"]),
fstore.doc_delete("firebase_discord_links", uid),
)
# Delete Firestore profile (sessions are kept for audit history)
await fstore.doc_delete("user_profiles", uid)
# Delete from Firebase Auth
await asyncio.to_thread(firebase_auth.delete_user, uid)
await audit.write_audit(
actor_uid=decoded["uid"],
actor_email=decoded.get("email", ""),
action="user.delete",
target_uid=uid,
target_email=email,
)
return {"ok": True}
+833 -65
View File
@@ -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<UserRole, string> = {
admin: "bg-indigo-900 text-indigo-300",
operator: "bg-green-900 text-green-300",
viewer: "bg-gray-800 text-gray-400",
};
function RoleBadge({ role }: { role: UserRole }) {
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
{labels[role]}
</span>
);
}
// ---------------------------------------------------------------------------
// AI Features tab
// ---------------------------------------------------------------------------
function FeaturesTab() {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
c2api.getFeatureFlags()
.then((f) => setFlags(f as unknown as FeatureFlags))
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
if (!flags) return;
setSaving(key);
setError(null);
try {
const updated = await c2api.setFeatureFlags({ [key]: value });
setFlags(updated as unknown as FeatureFlags);
} catch (e) {
setError(String(e));
} finally {
setSaving(null);
}
}
return (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
</div>
))}
</div>
)}
</section>
);
}
// ---------------------------------------------------------------------------
// Correlation Debug tab
// ---------------------------------------------------------------------------
function CorrelationDebugTab() {
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<string, number> = {};
const signalCounts: Record<string, number> = {};
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<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
function UserDetailPanel({
user,
onClose,
onUpdated,
currentUid,
}: {
user: UserRecord;
onClose: () => void;
onUpdated: (u: UserRecord) => void;
currentUid: string;
}) {
const [detail, setDetail] = useState<UserRecord>(user);
const [editRole, setEditRole] = useState<UserRole>(user.role);
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
const [editName, setEditName] = useState<string>(user.display_name ?? "");
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [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 (
<div className="max-w-2xl space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{(["features", "correlation"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{t === "features" ? "AI Features" : "Correlation Debug"}
</button>
))}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{detail.email}</p>
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
</div>
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
</div>
{tab === "features" && (
<section className="space-y-3">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{FLAG_META.map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<p className="text-white text-sm font-semibold">{label}</p>
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
</div>
<Toggle
enabled={flags?.[key] ?? true}
onChange={(val) => handleToggle(key, val)}
disabled={saving === key}
/>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-xs">{error}</p>
</div>
)}
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
<input
value={editName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{editRole === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={editNodes}
onChange={(e) => setEditNodes(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-1.5 rounded-lg transition-colors"
>
{saving ? "Saving…" : "Save changes"}
</button>
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
{detail.disabled ? "Disabled" : "Active"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Discord</span>
<span className="text-gray-300">
{detail.discord_linked
? `@${detail.discord_username ?? detail.discord_user_id}`
: "Not linked"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Created</span>
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Last sign-in</span>
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
</div>
</div>
{(detail.sessions?.length ?? 0) > 0 && (
<div className="border-t border-gray-800 pt-4">
<button
onClick={() => setShowSessions((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
>
<span>{showSessions ? "▲" : "▼"}</span>
<span>Login history ({detail.sessions?.length} recent)</span>
</button>
{showSessions && (
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
{detail.sessions?.map((s) => (
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
<span>{fmtDatetime(s.timestamp)}</span>
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
</div>
))}
</div>
)}
</section>
</div>
)}
{tab === "correlation" && <CorrelationDebugTab />}
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
{!isSelf ? (
<>
<button
onClick={handleToggleDisabled}
disabled={toggling}
className="text-xs text-yellow-500 hover:text-yellow-400 disabled:opacity-50 transition-colors"
>
{toggling ? "…" : detail.disabled ? "Enable account" : "Disable account"}
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-400 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting…" : "Delete user"}
</button>
</>
) : (
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create User modal
// ---------------------------------------------------------------------------
function CreateUserModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (u: UserRecord) => void;
}) {
const [email, setEmail] = useState("");
const [displayName, setDisplayName] = useState("");
const [role, setRole] = useState<UserRole>("viewer");
const [nodeIds, setNodeIds] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const owned_node_ids = role === "operator"
? nodeIds.split(",").map((s) => s.trim()).filter(Boolean)
: [];
try {
const created = await c2api.createUser({
email,
role,
display_name: displayName || undefined,
owned_node_ids,
});
onCreated(created);
if (created.invite_link) {
setInviteLink(created.invite_link);
} else {
onClose();
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
function copyLink() {
if (!inviteLink) return;
navigator.clipboard?.writeText(inviteLink).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
if (inviteLink) {
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
<h2 className="text-white font-semibold">User Created</h2>
<p className="text-xs text-gray-400">
Share this one-time invite link with the new user so they can set their password.
It expires after use.
</p>
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
</div>
<div className="flex gap-3">
<button
onClick={copyLink}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
{copied ? "Copied!" : "Copy link"}
</button>
<button
onClick={onClose}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg py-2 text-sm transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
<h2 className="text-white font-semibold mb-4">Create User</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="user@example.com"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Display Name <span className="text-gray-600">(optional)</span>
</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Jane Smith"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
<option value="admin">Admin full access</option>
<option value="operator">Operator owns nodes</option>
<option value="viewer">Viewer read-only</option>
</select>
</div>
{role === "operator" && (
<div>
<label className="text-xs text-gray-400 block mb-1">
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
</label>
<input
value={nodeIds}
onChange={(e) => setNodeIds(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="node-abc123, node-def456"
/>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Creating…" : "Create user"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Users tab
// ---------------------------------------------------------------------------
function UsersTab({ currentUid }: { currentUid: string }) {
const [users, setUsers] = useState<UserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUid, setSelectedUid] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
try {
const data = await c2api.listUsers();
setUsers(data);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
function handleUpdated(updated: UserRecord) {
if (updated.uid === "__deleted__") {
setUsers((prev) => prev.filter((u) => u.uid !== selectedUid));
setSelectedUid(null);
} else {
setUsers((prev) => prev.map((u) => u.uid === updated.uid ? { ...u, ...updated } : u));
}
}
function handleCreated(created: UserRecord) {
setUsers((prev) => [...prev, created]);
}
const selectedUser = users.find((u) => u.uid === selectedUid);
return (
<div className="space-y-4">
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
/>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
<button
onClick={() => setShowCreate(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
+ Create user
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : users.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No users found.</p>
) : (
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Email</th>
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
<th className="px-4 py-2.5 text-left">Role</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
<th className="px-4 py-2.5 text-left">Status</th>
<th className="px-4 py-2.5 w-16"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.uid}
className={`border-t border-gray-800 transition-colors ${
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
}`}
>
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
</td>
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
<td className="px-4 py-2.5">
{u.disabled
? <span className="text-red-500">Disabled</span>
: <span className="text-green-500">Active</span>
}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => setSelectedUid(selectedUid === u.uid ? null : u.uid)}
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
{selectedUid === u.uid ? "Close" : "Edit"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUid(null)}
onUpdated={handleUpdated}
currentUid={currentUid}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Audit Log tab
// ---------------------------------------------------------------------------
function AuditLogTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const PAGE = 50;
useEffect(() => {
c2api.getAuditLog(PAGE, 0)
.then((data) => {
setEntries(data);
setHasMore(data.length === PAGE);
})
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
async function loadMore() {
setLoadingMore(true);
try {
const more = await c2api.getAuditLog(PAGE, entries.length);
setEntries((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
} catch (e) {
setError(String(e));
} finally {
setLoadingMore(false);
}
}
function actionColor(action: string) {
if (action.includes("delete")) return "text-red-400";
if (action.includes("disable")) return "text-yellow-400";
if (action.includes("create")) return "text-green-400";
return "text-indigo-400";
}
return (
<div className="space-y-4">
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{loading ? (
<p className="text-gray-500 text-sm font-mono">Loading</p>
) : entries.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
) : (
<>
<div className="border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
<th className="px-4 py-2.5 text-left">Time</th>
<th className="px-4 py-2.5 text-left">Action</th>
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
<th className="px-4 py-2.5 text-left">Details</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
{Object.keys(e.details).length > 0
? Object.entries(e.details)
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
.join(" · ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Stale Calls tab
// ---------------------------------------------------------------------------
function StaleCallsTab() {
const [minutes, setMinutes] = useState(30);
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(dryRun: boolean) {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await c2api.closeStallCalls(minutes, dryRun);
setResult(res);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5">
<p className="text-xs text-gray-500 font-mono">
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
Preview first, then close.
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
<input
type="number"
min={1} max={1440}
value={minutes}
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
<button
onClick={() => run(true)}
disabled={loading}
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{loading ? "Working…" : "Preview"}
</button>
<button
onClick={() => run(false)}
disabled={loading || result === null || result.count === 0}
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
>
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
</button>
</div>
{error && (
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
<p className="text-red-400 text-sm font-mono">{error}</p>
</div>
)}
{result && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
<p className="text-sm font-mono text-white">
{result.dry_run ? "Preview: " : "Closed: "}
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
{result.count} stale call{result.count !== 1 ? "s" : ""}
</span>
{result.count === 0 && <span className="text-gray-500"> nothing to clear</span>}
</p>
{result.call_ids.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-0.5">
{result.call_ids.map((id) => (
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
))}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main admin page
// ---------------------------------------------------------------------------
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
const TAB_LABELS: { key: AdminTab; label: string }[] = [
{ key: "features", label: "AI Features" },
{ key: "correlation", label: "Correlation Debug" },
{ key: "calls", label: "Calls" },
{ key: "users", label: "Users" },
{ key: "audit", label: "Audit Log" },
];
export default function AdminPage() {
const { user, isAdmin } = useAuth();
const router = useRouter();
const [tab, setTab] = useState<AdminTab>("features");
useEffect(() => {
if (!isAdmin) router.replace("/dashboard");
}, [isAdmin, router]);
if (!isAdmin) return null;
// Users/Audit tabs benefit from full width; everything else is narrow
const wide = tab === "users" || tab === "audit";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
{TAB_LABELS.map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
}`}
>
{label}
</button>
))}
</div>
{tab === "features" && <FeaturesTab />}
{tab === "correlation" && <CorrelationDebugTab />}
{tab === "calls" && <StaleCallsTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div>
);
}
+3
View File
@@ -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.");
+11 -1
View File
@@ -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<NodeRecord | null>(null);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
interface LinkStatus {
linked: boolean;
discord_user_id?: string;
discord_username?: string;
linked_at?: string;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
function Initials({ name }: { name: string }) {
const parts = name.trim().split(/\s+/);
const letters = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: name.slice(0, 2);
return (
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
{letters.toUpperCase()}
</div>
);
}
export default function ProfilePage() {
const { user, isAdmin, role, signOut } = useAuth();
const router = useRouter();
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
const [linkLoading, setLinkLoading] = useState(true);
const [code, setCode] = useState<string | null>(null);
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
const [generating, setGenerating] = useState(false);
const [unlinking, setUnlinking] = useState(false);
useEffect(() => {
if (!user) return;
c2api.getLinkStatus()
.then(setLinkStatus)
.catch(() => setLinkStatus({ linked: false }))
.finally(() => setLinkLoading(false));
}, [user]);
async function generateCode() {
setGenerating(true);
try {
const res = await c2api.generateLinkCode();
if (res.already_linked) {
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
} else if (res.code) {
setCode(res.code);
setCodeExpiry(res.expires_minutes ?? 15);
}
} finally {
setGenerating(false);
}
}
async function unlink() {
setUnlinking(true);
try {
await c2api.unlinkDiscord();
setLinkStatus({ linked: false });
setCode(null);
} finally {
setUnlinking(false);
}
}
async function handleSignOut() {
await signOut();
router.push("/login");
}
if (!user) return null;
const displayName = user.displayName || user.email || "Account";
return (
<div className="max-w-lg space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Initials name={displayName} />
<div>
<h1 className="text-white text-xl font-bold">{displayName}</h1>
{user.displayName && user.email && (
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
)}
{role && (
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
role === "admin" ? "bg-indigo-900 text-indigo-300" :
role === "operator" ? "bg-green-900 text-green-300" :
"bg-gray-800 text-gray-400"
}`}>
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
</span>
)}
</div>
</div>
{/* Firebase account */}
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
<div className="space-y-2">
<Row label="Email" value={user.email ?? "—"} />
<Row label="UID" value={user.uid} mono truncate />
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
{user.metadata.creationTime && (
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
)}
{user.metadata.lastSignInTime && (
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
)}
</div>
</div>
</section>
{/* Discord linking */}
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
{linkLoading ? (
<p className="text-gray-500 text-sm">Loading</p>
) : linkStatus?.linked ? (
<div className="space-y-3">
<div className="space-y-2">
{linkStatus.discord_username && (
<Row label="Username" value={`@${linkStatus.discord_username}`} />
)}
{linkStatus.discord_user_id && (
<Row label="User ID" value={linkStatus.discord_user_id} mono />
)}
{linkStatus.linked_at && (
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
)}
</div>
<div className="pt-1">
<button
onClick={unlink}
disabled={unlinking}
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
>
{unlinking ? "Unlinking…" : "Unlink Discord account"}
</button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Link your Discord account to access private trips from both the web and Discord.
</p>
{code ? (
<div className="space-y-2">
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
</div>
<p className="text-xs text-gray-400">
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
</p>
<button
onClick={generateCode}
disabled={generating}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
>
Generate new code
</button>
</div>
) : (
<button
onClick={generateCode}
disabled={generating}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
{generating ? "Generating…" : "Get link code"}
</button>
)}
</div>
)}
</div>
</section>
{/* Sign out */}
<section className="bg-gray-900 border border-gray-800 rounded-xl">
<div className="px-4 py-3 flex items-center justify-between">
<p className="text-sm text-gray-400">Sign out of this device</p>
<button
onClick={handleSignOut}
className="text-sm text-red-500 hover:text-red-400 transition-colors"
>
Sign out
</button>
</div>
</section>
</div>
);
}
function Row({ label, value, mono = false, truncate = false }: {
label: string;
value: string;
mono?: boolean;
truncate?: boolean;
}) {
return (
<div className="flex items-start justify-between gap-4">
<span className="text-xs text-gray-500 shrink-0">{label}</span>
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
{value}
</span>
</div>
);
}
+11 -1
View File
@@ -1,8 +1,10 @@
"use client";
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<SystemRecord | null | "new">(null);
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
+4 -4
View File
@@ -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<TokenRecord[]>([]);
const [loading, setLoading] = useState(true);
@@ -26,8 +26,8 @@ export default function TokensPage() {
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
+388 -36
View File
@@ -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<string> {
// ---------------------------------------------------------------------------
// 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 (
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
{tag}
</span>
);
}
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>();
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<void>;
prefill?: Partial<TripEvent>;
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<string[]>(prefill?.tags ?? []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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"
>
<h2 className="text-white font-bold text-lg">Add Event</h2>
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div>
<label className="text-xs text-gray-400 block mb-1">Title</label>
@@ -272,6 +314,29 @@ function AddEventModal({
/>
</div>
{availableTags.length > 0 && (
<div>
<label className="text-xs text-gray-400 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
}`}
>
{tag}
</button>
);
})}
</div>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1">
@@ -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")}
</button>
</div>
</form>
@@ -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({
</div>
))}
{/* 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 (
<div key={e.event_id} className="group">
{/* Shaded band (only if duration given) */}
{e.end_time && (
<div
style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
/>
)}
{/* Dashed marker line + label */}
<div
style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2"
>
<div className="flex-1 border-t border-dashed border-gray-600/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)}
</span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
</div>
)}
</div>
</div>
);
})}
{/* 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({
<div key={e.event_id}>
<div
style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors ${
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict
? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
@@ -392,6 +503,11 @@ function DayTimeline({
{height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
)}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{e.maps_link && (
@@ -406,21 +522,28 @@ function DayTimeline({
</a>
)}
{isAdmin && (
<button
onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
<>
<button
onClick={() => onEdit(e)}
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Edit
</button>
<button
onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</>
)}
</div>
</div>
</div>
{/* Drive time badge below this event, if present */}
{drive && (
<div
style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1"
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
>
<span className="text-gray-700"></span> {drive} drive
</div>
@@ -446,6 +569,11 @@ function DayTimeline({
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
)}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{e.maps_link && (
@@ -455,10 +583,16 @@ function DayTimeline({
</a>
)}
{isAdmin && (
<button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
×
</button>
<>
<button onClick={() => onEdit(e)}
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit
</button>
<button onClick={() => onDelete(e.event_id)}
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
×
</button>
</>
)}
</div>
</div>
@@ -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<ChatMessage[]>([]);
const storageKey = CHAT_STORAGE_KEY(trip.trip_id);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
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<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-800 shrink-0">
<p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5">
Tell me what you want to do I can search places and suggest events.
</p>
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2">
<div>
<p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-gray-500 text-xs mt-0.5">
Tell me what you want to do I can search places and suggest events.
</p>
</div>
{messages.length > 0 && (
<button
onClick={clearChat}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5"
>
Clear
</button>
)}
</div>
{/* Messages */}
@@ -610,7 +775,24 @@ function AssistantPanel({
: "bg-gray-800 text-gray-200"
}`}
>
{msg.content}
{msg.role === "user" ? msg.content : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 my-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 underline">
{children}
</a>
),
}}
>
{msg.content}
</ReactMarkdown>
)}
</div>
{/* Suggestion cards */}
@@ -636,6 +818,11 @@ function AssistantPanel({
)}
{s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
</div>
)}
</div>
{s.maps_link && (
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
@@ -680,13 +867,15 @@ function AssistantPanel({
{/* Input */}
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
<div className="flex gap-2">
<input
<textarea
ref={inputRef}
value={input}
onChange={(e) => 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"
/>
<button
onClick={send}
@@ -716,7 +905,10 @@ export default function TripDetailPage() {
const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState<string>("");
const [showAdd, setShowAdd] = useState(false);
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const [inviteInput, setInviteInput] = useState("");
const load = useCallback(async () => {
try {
@@ -774,6 +966,18 @@ export default function TripDetailPage() {
} catch (e) { console.error(e); }
}
async function handleUpdateEvent(body: object) {
if (!trip || !editEvent) return;
const updated = await c2api.updateTripEvent(trip.trip_id, editEvent.event_id, body);
setTrip((prev) => {
if (!prev) return prev;
const events = prev.events.map((e) => e.event_id === updated.event_id ? updated : e)
.sort((a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? ""));
return { ...prev, events };
});
setEditEvent(null);
}
async function handleAddEvent(body: object) {
if (!trip) return;
const event = await c2api.createTripEvent(trip.trip_id, body);
@@ -798,6 +1002,57 @@ export default function TripDetailPage() {
);
return { ...prev, events };
});
// Refresh trip to pick up any new tags the AI may have created
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
}
async function handleAddTag() {
if (!trip || !tagInput.trim()) return;
const tag = tagInput.trim();
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
const available = [...(trip.available_tags ?? []), tag];
const overlap = trip.overlap_tags ?? [];
await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
setTagInput("");
}
async function handleRemoveTag(tag: string) {
if (!trip) return;
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
}
async function handleToggleVisibility() {
if (!trip) return;
const next = trip.visibility === "private" ? "public" : "private";
await c2api.setTripVisibility(trip.trip_id, next);
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
}
async function handleInvite() {
const discordId = inviteInput.trim();
if (!trip || !discordId) return;
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
await c2api.inviteToTrip(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
setInviteInput("");
}
async function handleRevokeInvite(discordId: string) {
if (!trip) return;
await c2api.revokeInvite(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : prev);
}
async function handleToggleOverlap(tag: string) {
if (!trip) return;
const current = trip.overlap_tags ?? [];
const overlap = current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag];
await c2api.updateTripTags(trip.trip_id, trip.available_tags ?? [], overlap);
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
}
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>;
@@ -807,7 +1062,7 @@ export default function TripDetailPage() {
const attendees = Object.values(trip.attendees ?? {});
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
const hasConflict = (day: string) =>
detectConflicts(trip.events.filter((e) => e.date === day)).size > 0;
detectConflicts(trip.events.filter((e) => e.date === day), trip.overlap_tags ?? []).size > 0;
return (
<div className="space-y-6">
@@ -842,7 +1097,13 @@ export default function TripDetailPage() {
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Visibility badge */}
{trip.visibility === "private" && (
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
🔒 private
</span>
)}
{isAdmin && (
<>
<button
@@ -851,6 +1112,12 @@ export default function TripDetailPage() {
>
+ Add Event
</button>
<button
onClick={handleToggleVisibility}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
>
{trip.visibility === "private" ? "Make public" : "Make private"}
</button>
<button
onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
@@ -861,6 +1128,77 @@ export default function TripDetailPage() {
)}
</div>
</div>
{/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{(trip.available_tags ?? []).map((tag) => {
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
return (
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
{tag}
{isAdmin && (
<>
<button
onClick={() => handleToggleOverlap(tag)}
title={isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap"}
className={`leading-none transition-colors ${isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70"}`}
>
</button>
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none opacity-60 hover:opacity-100">×</button>
</>
)}
</span>
);
})}
{isAdmin && (
<div className="flex items-center gap-1">
<input
value={tagInput}
onChange={(e) => 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"
/>
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
)}
</div>
)}
{/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && (
<div className="space-y-2">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-gray-600">No invites yet</span>
)}
{(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
<span className="font-mono">{discordId}</span>
<button
onClick={() => handleRevokeInvite(discordId)}
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={inviteInput}
onChange={(e) => 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"
/>
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
</div>
</div>
)}
</div>
{/* 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 ?? []}
/>
</div>
</div>
@@ -927,6 +1268,17 @@ export default function TripDetailPage() {
prefill={selectedDay ? { date: selectedDay } : undefined}
/>
)}
{/* Edit event modal */}
{editEvent && (
<AddEventModal
trip={trip}
onClose={() => setEditEvent(null)}
onAdd={handleUpdateEvent}
prefill={editEvent}
editEventId={editEvent.event_id}
/>
)}
</div>
);
}
+29 -5
View File
@@ -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<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
role: null,
isAdmin: false,
isOperator: false,
ownedNodeIds: [],
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [role, setRole] = useState<UserRole | null>(null);
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
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 (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
{children}
</AuthContext.Provider>
);
+42 -23
View File
@@ -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" ? <SunIcon /> : <MoonIcon />}
</button>
{/* Sign out (desktop) */}
{/* Profile avatar (desktop) */}
<button
onClick={signOut}
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
onClick={() => router.push("/profile")}
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
pathname.startsWith("/profile")
? "bg-indigo-600 text-white"
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
}`}
title="Profile"
>
Sign out
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
</button>
{/* Hamburger (mobile) */}
@@ -154,12 +170,15 @@ export function Nav() {
</Link>
))}
<div className="border-t border-gray-800 pt-3 mt-1">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
}`}
>
Sign out
</button>
Profile
</Link>
</div>
</div>
)}
+48
View File
@@ -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<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
deleteTrip: (id: string) =>
request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
setTripVisibility: (id: string, visibility: "public" | "private") =>
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
inviteToTrip: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
revokeInvite: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
generateLinkCode: () =>
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
getLinkStatus: () =>
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
unlinkDiscord: () =>
request("/auth/link", { method: "DELETE" }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTripEvent: (tripId: string, eventId: string) =>
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
@@ -168,4 +186,34 @@ export const c2api = {
method: "PUT",
body: JSON.stringify(flags),
}),
// User management (admin only)
listUsers: () =>
request<import("@/lib/types").UserRecord[]>("/admin/users"),
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
method: "POST",
body: JSON.stringify(body),
}),
getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
disableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
enableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
deleteUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
// Audit log (admin only)
getAuditLog: (limit = 50, offset = 0) =>
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
// Session recording — called on each explicit sign-in
recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
};
+44
View File
@@ -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<string, unknown>;
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<string, string>;
created_at: string;
}
@@ -132,6 +172,10 @@ export interface TripRecord {
start_date: string;
end_date: string;
attendees: Record<string, string>;
available_tags: string[];
overlap_tags: string[];
visibility: "public" | "private";
invited_discord_ids: string[];
created_at: string;
events?: TripEvent[];
}
+2 -1
View File
@@ -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",
+100 -10
View File
@@ -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))
@@ -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: