Merge remote-tracking branch 'origin/main' into build-infrastructure
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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.");
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user