Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ff9f8ea3 | |||
| 9fdcad1c46 | |||
| 33700448bf | |||
| 3defdf18dc | |||
| 1f17b6c0d2 | |||
| 961cc6f36e | |||
| d290b89736 | |||
| 758c6f4115 | |||
| 6ae4d398f8 | |||
| 981f03ac06 | |||
| 47430827d4 | |||
| 4295bdf4d2 | |||
| a1c91c5ed3 |
@@ -0,0 +1,89 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & push images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.vpn.cusano.net
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.BUILD_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push c2-core
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-c2-core
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/c2-core:latest
|
||||||
|
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push discord-bot
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-server-discord-bot
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/discord-bot:latest
|
||||||
|
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-frontend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/frontend:latest
|
||||||
|
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to VM
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Write SSH key
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||||
|
chmod 600 /tmp/deploy_key
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=no \
|
||||||
|
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||||
|
-i /tmp/deploy_key \
|
||||||
|
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||||
|
set -e
|
||||||
|
cd /opt/drb
|
||||||
|
|
||||||
|
# Update compose files + mosquitto config
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Pull pre-built images and restart (no build on the VM)
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
sleep 20
|
||||||
|
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
||||||
|
(echo "Health check failed" && exit 1)
|
||||||
+12
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
|
|||||||
drb-frontend/.env
|
drb-frontend/.env
|
||||||
drb-c2-core/gcp-key.json
|
drb-c2-core/gcp-key.json
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
infra/.terraform/
|
||||||
|
infra/terraform.tfstate
|
||||||
|
infra/terraform.tfstate.backup
|
||||||
|
infra/terraform.tfstate.*.backup
|
||||||
|
infra/.terraform.lock.hcl
|
||||||
|
infra/terraform.tfvars
|
||||||
|
infra/tf.log
|
||||||
|
infra/ansible/inventory.ini
|
||||||
|
infra/ansible/group_vars/all.yml
|
||||||
|
infra/ansible/vault.yml
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Production overrides — used on the VM.
|
||||||
|
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
#
|
||||||
|
# Differences from dev:
|
||||||
|
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
|
||||||
|
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
|
||||||
|
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
|
||||||
|
# - restart: always (instead of unless-stopped) for hard reboots.
|
||||||
|
|
||||||
|
services:
|
||||||
|
mosquitto:
|
||||||
|
restart: always
|
||||||
|
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
|
||||||
|
|
||||||
|
c2-core:
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||||
|
|
||||||
|
discord-bot:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||||
+3
-2
@@ -17,17 +17,17 @@ services:
|
|||||||
- mosquitto_data:/mosquitto/data
|
- mosquitto_data:/mosquitto/data
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
|
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||||
build: ./drb-c2-core
|
build: ./drb-c2-core
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8888:8000"
|
- "8888:8000"
|
||||||
env_file: ./drb-c2-core/.env
|
env_file: ./drb-c2-core/.env
|
||||||
volumes:
|
|
||||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- mosquitto
|
- mosquitto
|
||||||
|
|
||||||
discord-bot:
|
discord-bot:
|
||||||
|
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||||
build: ./drb-server-discord-bot
|
build: ./drb-server-discord-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./drb-server-discord-bot/.env
|
env_file: ./drb-server-discord-bot/.env
|
||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||||
build: ./drb-frontend
|
build: ./drb-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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")
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
|
||||||
|
def get_role(decoded: dict) -> str:
|
||||||
|
"""Extract the effective role from a decoded Firebase token.
|
||||||
|
|
||||||
|
Checks the granular ``role`` claim first, then falls back to the legacy
|
||||||
|
``admin`` boolean so existing tokens continue to work during the transition.
|
||||||
|
"""
|
||||||
|
if decoded.get("role") == "admin" or decoded.get("admin"):
|
||||||
|
return "admin"
|
||||||
|
role = decoded.get("role", "viewer")
|
||||||
|
return role if role in ("admin", "operator", "viewer") else "viewer"
|
||||||
|
|
||||||
|
|
||||||
async def require_admin_token(
|
async def require_admin_token(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Verify a Firebase ID token AND require the admin custom claim."""
|
"""Verify a Firebase ID token AND require the admin role.
|
||||||
|
|
||||||
|
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
||||||
|
``role: "admin"`` claim so tokens issued before the role migration still work.
|
||||||
|
"""
|
||||||
decoded = await require_firebase_token(credentials)
|
decoded = await require_firebase_token(credentials)
|
||||||
if not decoded.get("admin"):
|
if get_role(decoded) != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
@@ -77,7 +93,7 @@ async def require_service_key_or_admin(
|
|||||||
decoded = firebase_auth.verify_id_token(token)
|
decoded = firebase_auth.verify_id_token(token)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
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")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return decoded
|
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.internal.recorrelation_sweep import recorrelation_loop
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
@@ -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(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
|
app.include_router(users.router) # auth: admin only
|
||||||
|
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ class TripCreate(BaseModel):
|
|||||||
start_date: str # YYYY-MM-DD
|
start_date: str # YYYY-MM-DD
|
||||||
end_date: str # YYYY-MM-DD
|
end_date: str # YYYY-MM-DD
|
||||||
available_tags: List[str] = [] # tag labels configured for this trip
|
available_tags: List[str] = [] # tag labels configured for this trip
|
||||||
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
||||||
|
visibility: str = "public" # "public" | "private"
|
||||||
|
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
||||||
|
|
||||||
|
|
||||||
class TripEventCreate(BaseModel):
|
class TripEventCreate(BaseModel):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from app.internal.auth import require_admin_token, require_firebase_token
|
|||||||
from app.internal.feature_flags import get_flags, set_flags
|
from app.internal.feature_flags import get_flags, set_flags
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||||
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||||
global_stt = global_flags.get("stt_enabled", True)
|
global_stt = global_flags.get("stt_enabled", True)
|
||||||
@@ -163,3 +162,15 @@ async def debug_correlation(
|
|||||||
"incidents": incident_records,
|
"incidents": incident_records,
|
||||||
"orphaned_calls": orphans[:250],
|
"orphaned_calls": orphans[:250],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit")
|
||||||
|
async def get_audit_log(
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
_=Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""Return paginated audit log entries, most recent first."""
|
||||||
|
entries = await fstore.collection_list("audit_log")
|
||||||
|
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
||||||
|
return entries[offset: offset + limit]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -59,6 +60,50 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
|||||||
return {"ok": True, "call_id": call_id}
|
return {"ok": True, "call_id": call_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/close-stale")
|
||||||
|
async def close_stale_calls(
|
||||||
|
older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."),
|
||||||
|
dry_run: bool = Query(False, description="If true, return what would be closed without writing."),
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Find and close calls stuck in 'active' status — e.g. because a node rebooted
|
||||||
|
before sending an end-call event. Returns the list of affected call IDs.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
|
||||||
|
active_calls = await fstore.collection_list("calls", status="active")
|
||||||
|
|
||||||
|
stale = []
|
||||||
|
for call in active_calls:
|
||||||
|
started_raw = call.get("started_at")
|
||||||
|
if not started_raw:
|
||||||
|
continue
|
||||||
|
if isinstance(started_raw, datetime):
|
||||||
|
started = started_raw if started_raw.tzinfo else started_raw.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
started = datetime.fromisoformat(str(started_raw).replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if started < cutoff:
|
||||||
|
stale.append(call)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
for call in stale:
|
||||||
|
await fstore.doc_set("calls", call["call_id"], {
|
||||||
|
"status": "ended",
|
||||||
|
"ended_at": now_iso,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"older_than_minutes": older_than_minutes,
|
||||||
|
"count": len(stale),
|
||||||
|
"call_ids": [c["call_id"] for c in stale],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{call_id}/transcript")
|
@router.patch("/{call_id}/transcript")
|
||||||
async def patch_transcript(
|
async def patch_transcript(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -18,6 +18,32 @@ from app.internal.auth import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Access control helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
||||||
|
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||||
|
return (link or {}).get("discord_user_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
|
||||||
|
"""Return True if the caller may read this trip."""
|
||||||
|
if is_service:
|
||||||
|
return True # bot sees all; it filters client-side per-user
|
||||||
|
if trip.get("visibility", "public") == "public":
|
||||||
|
return True
|
||||||
|
if not firebase_uid:
|
||||||
|
return False
|
||||||
|
# attendees keyed by discord_id — check linked discord_id
|
||||||
|
if discord_id:
|
||||||
|
if discord_id in trip.get("attendees", {}):
|
||||||
|
return True
|
||||||
|
if discord_id in trip.get("invited_discord_ids", []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# AI assistant — tool definitions
|
# AI assistant — tool definitions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -189,8 +215,12 @@ class ChatRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_trips():
|
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
return await fstore.collection_list("trips")
|
trips = await fstore.collection_list("trips")
|
||||||
|
is_service = bool(decoded.get("service"))
|
||||||
|
firebase_uid = decoded.get("uid")
|
||||||
|
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||||
|
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
@@ -208,6 +238,9 @@ async def create_trip(body: TripCreate):
|
|||||||
"end_date": body.end_date,
|
"end_date": body.end_date,
|
||||||
"attendees": {}, # {discord_user_id: discord_username}
|
"attendees": {}, # {discord_user_id: discord_username}
|
||||||
"available_tags": body.available_tags,
|
"available_tags": body.available_tags,
|
||||||
|
"overlap_tags": body.overlap_tags,
|
||||||
|
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
|
||||||
|
"invited_discord_ids": body.invited_discord_ids,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||||
@@ -215,24 +248,30 @@ async def create_trip(body: TripCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{trip_id}")
|
@router.get("/{trip_id}")
|
||||||
async def get_trip(trip_id: str):
|
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
is_service = bool(decoded.get("service"))
|
||||||
|
firebase_uid = decoded.get("uid")
|
||||||
|
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||||
|
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
|
||||||
|
raise HTTPException(403, "This trip is private.")
|
||||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||||
events.sort(key=lambda e: (e["date"], e.get("time") or ""))
|
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
|
||||||
return {**trip, "events": events}
|
return {**trip, "events": events}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{trip_id}/tags")
|
@router.put("/{trip_id}/tags")
|
||||||
async def update_trip_tags(trip_id: str, body: dict):
|
async def update_trip_tags(trip_id: str, body: dict):
|
||||||
"""Replace the trip's available tag list."""
|
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
tags = [str(t) for t in body.get("available_tags", []) if t]
|
tags = [str(t) for t in body.get("available_tags", []) if t]
|
||||||
await fstore.doc_update("trips", trip_id, {"available_tags": tags})
|
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
|
||||||
return {"available_tags": tags}
|
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
|
||||||
|
return {"available_tags": tags, "overlap_tags": overlap}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}")
|
@router.delete("/{trip_id}")
|
||||||
@@ -257,12 +296,49 @@ async def join_trip(
|
|||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
if trip.get("visibility", "public") == "private":
|
||||||
|
invited = trip.get("invited_discord_ids", [])
|
||||||
|
attendees_existing = trip.get("attendees", {})
|
||||||
|
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
|
||||||
|
raise HTTPException(403, "This trip is private. You need an invite to join.")
|
||||||
attendees = trip.get("attendees", {})
|
attendees = trip.get("attendees", {})
|
||||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||||
return {"ok": True, "attendees": attendees}
|
return {"ok": True, "attendees": attendees}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{trip_id}/visibility")
|
||||||
|
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
|
||||||
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
visibility = body.get("visibility", "public")
|
||||||
|
if visibility not in ("public", "private"):
|
||||||
|
raise HTTPException(400, "visibility must be 'public' or 'private'.")
|
||||||
|
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
|
||||||
|
return {"visibility": visibility}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{trip_id}/invite/{discord_user_id}")
|
||||||
|
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||||
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
|
||||||
|
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||||
|
return {"ok": True, "invited_discord_ids": invited}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{trip_id}/invite/{discord_user_id}")
|
||||||
|
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||||
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
|
if not trip:
|
||||||
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
|
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
|
||||||
|
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||||
|
return {"ok": True, "invited_discord_ids": invited}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/leave")
|
@router.post("/{trip_id}/leave")
|
||||||
async def leave_trip(
|
async def leave_trip(
|
||||||
trip_id: str,
|
trip_id: str,
|
||||||
|
|||||||
@@ -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}
|
||||||
+822
-54
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { UserRecord, AuditEntry, UserRole } from "@/lib/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared primitives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface FeatureFlags {
|
interface FeatureFlags {
|
||||||
stt_enabled: boolean;
|
stt_enabled: boolean;
|
||||||
@@ -61,6 +66,99 @@ function Toggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDatetime(iso: string | null | undefined) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short", day: "numeric", year: "numeric",
|
||||||
|
hour: "numeric", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<UserRole, string> = {
|
||||||
|
admin: "bg-indigo-900 text-indigo-300",
|
||||||
|
operator: "bg-green-900 text-green-300",
|
||||||
|
viewer: "bg-gray-800 text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function RoleBadge({ role }: { role: UserRole }) {
|
||||||
|
const labels: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${ROLE_COLORS[role]}`}>
|
||||||
|
{labels[role]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Features tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FeaturesTab() {
|
||||||
|
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.getFeatureFlags()
|
||||||
|
.then((f) => setFlags(f as unknown as FeatureFlags))
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
||||||
|
if (!flags) return;
|
||||||
|
setSaving(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.setFeatureFlags({ [key]: value });
|
||||||
|
setFlags(updated as unknown as FeatureFlags);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||||
|
{FLAG_META.map(({ key, label, description }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-white text-sm font-semibold">{label}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={flags?.[key] ?? true}
|
||||||
|
onChange={(val) => handleToggle(key, val)}
|
||||||
|
disabled={saving === key}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Correlation Debug tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function CorrelationDebugTab() {
|
function CorrelationDebugTab() {
|
||||||
const [limit, setLimit] = useState(20);
|
const [limit, setLimit] = useState(20);
|
||||||
const [orphanHours, setOrphanHours] = useState(48);
|
const [orphanHours, setOrphanHours] = useState(48);
|
||||||
@@ -121,7 +219,6 @@ function CorrelationDebugTab() {
|
|||||||
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
|
orphans_by_talkgroup?: Array<{ talkgroup_id?: number; talkgroup_name?: string; count: number; no_type_count: number; sweep_exhausted_count: number }>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
// Aggregate corr_path and corr_fit_signal counts across all incident calls.
|
|
||||||
const pathCounts: Record<string, number> = {};
|
const pathCounts: Record<string, number> = {};
|
||||||
const signalCounts: Record<string, number> = {};
|
const signalCounts: Record<string, number> = {};
|
||||||
if (meta?.incidents) {
|
if (meta?.incidents) {
|
||||||
@@ -245,91 +342,762 @@ function CorrelationDebugTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
// ---------------------------------------------------------------------------
|
||||||
const { isAdmin } = useAuth();
|
// User detail panel
|
||||||
const router = useRouter();
|
// ---------------------------------------------------------------------------
|
||||||
const [tab, setTab] = useState<"features" | "correlation">("features");
|
|
||||||
|
|
||||||
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
function UserDetailPanel({
|
||||||
const [loading, setLoading] = useState(true);
|
user,
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
onClose,
|
||||||
|
onUpdated,
|
||||||
|
currentUid,
|
||||||
|
}: {
|
||||||
|
user: UserRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: (u: UserRecord) => void;
|
||||||
|
currentUid: string;
|
||||||
|
}) {
|
||||||
|
const [detail, setDetail] = useState<UserRecord>(user);
|
||||||
|
const [editRole, setEditRole] = useState<UserRole>(user.role);
|
||||||
|
const [editNodes, setEditNodes] = useState<string>(user.owned_node_ids.join(", "));
|
||||||
|
const [editName, setEditName] = useState<string>(user.display_name ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showSessions, setShowSessions] = useState(false);
|
||||||
|
|
||||||
|
// Fetch full detail (sessions) lazily
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) {
|
c2api.getUser(user.uid)
|
||||||
router.replace("/dashboard");
|
.then((d) => setDetail(d))
|
||||||
return;
|
.catch(() => {});
|
||||||
}
|
}, [user.uid]);
|
||||||
c2api.getFeatureFlags()
|
|
||||||
.then((f) => setFlags(f as unknown as FeatureFlags))
|
|
||||||
.catch((e) => setError(String(e)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [isAdmin, router]);
|
|
||||||
|
|
||||||
async function handleToggle(key: keyof FeatureFlags, value: boolean) {
|
async function handleSave() {
|
||||||
if (!flags) return;
|
setSaving(true);
|
||||||
setSaving(key);
|
setError(null);
|
||||||
|
const nodes = editRole === "operator"
|
||||||
|
? editNodes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
try {
|
||||||
|
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(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleDisabled() {
|
||||||
|
setToggling(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await c2api.setFeatureFlags({ [key]: value });
|
if (detail.disabled) {
|
||||||
setFlags(updated as unknown as FeatureFlags);
|
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="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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) return null;
|
function handleCreated(created: UserRecord) {
|
||||||
|
setUsers((prev) => [...prev, created]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedUser = users.find((u) => u.uid === selectedUid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="space-y-4">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
{showCreate && (
|
||||||
|
<CreateUserModal
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={(u) => { handleCreated(u); setShowCreate(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<div className="flex items-center justify-between">
|
||||||
{(["features", "correlation"] as const).map((t) => (
|
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||||||
<button
|
<button
|
||||||
key={t}
|
onClick={() => setShowCreate(true)}
|
||||||
onClick={() => setTab(t)}
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
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"}
|
+ Create user
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "features" && (
|
|
||||||
<section className="space-y-3">
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
<p className="text-red-400 text-sm font-mono">{error}</p>
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<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="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||||
{FLAG_META.map(({ key, label, description }) => (
|
<table className="w-full text-xs font-mono">
|
||||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
<thead>
|
||||||
<div className="min-w-0">
|
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||||
<p className="text-white text-sm font-semibold">{label}</p>
|
<th className="px-4 py-2.5 text-left">Email</th>
|
||||||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
||||||
</div>
|
<th className="px-4 py-2.5 text-left">Role</th>
|
||||||
<Toggle
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Discord</th>
|
||||||
enabled={flags?.[key] ?? true}
|
<th className="px-4 py-2.5 text-left hidden md:table-cell">Last sign-in</th>
|
||||||
onChange={(val) => handleToggle(key, val)}
|
<th className="px-4 py-2.5 text-left">Status</th>
|
||||||
disabled={saving === key}
|
<th className="px-4 py-2.5 w-16"></th>
|
||||||
/>
|
</tr>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{selectedUser && (
|
||||||
|
<UserDetailPanel
|
||||||
|
user={selectedUser}
|
||||||
|
onClose={() => setSelectedUid(null)}
|
||||||
|
onUpdated={handleUpdated}
|
||||||
|
currentUid={currentUid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audit Log tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AuditLogTab() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const PAGE = 50;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
c2api.getAuditLog(PAGE, 0)
|
||||||
|
.then((data) => {
|
||||||
|
setEntries(data);
|
||||||
|
setHasMore(data.length === PAGE);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const more = await c2api.getAuditLog(PAGE, entries.length);
|
||||||
|
setEntries((prev) => [...prev, ...more]);
|
||||||
|
setHasMore(more.length === PAGE);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionColor(action: string) {
|
||||||
|
if (action.includes("delete")) return "text-red-400";
|
||||||
|
if (action.includes("disable")) return "text-yellow-400";
|
||||||
|
if (action.includes("create")) return "text-green-400";
|
||||||
|
return "text-indigo-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-xs font-mono">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
||||||
|
<th className="px-4 py-2.5 text-left">Time</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Action</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||||||
|
<th className="px-4 py-2.5 text-left hidden md:table-cell">Target</th>
|
||||||
|
<th className="px-4 py-2.5 text-left">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
||||||
|
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||||||
|
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
|
||||||
|
{Object.keys(e.details).length > 0
|
||||||
|
? Object.entries(e.details)
|
||||||
|
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||||
|
.join(" · ")
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="text-sm font-mono text-indigo-400 hover:text-indigo-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stale Calls tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StaleCallsTab() {
|
||||||
|
const [minutes, setMinutes] = useState(30);
|
||||||
|
const [result, setResult] = useState<{ dry_run: boolean; count: number; call_ids: string[] } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function run(dryRun: boolean) {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await c2api.closeStallCalls(minutes, dryRun);
|
||||||
|
setResult(res);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
||||||
|
Preview first, then close.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1} max={1440}
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
||||||
|
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => run(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Working…" : "Preview"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => run(false)}
|
||||||
|
disabled={loading || result === null || result.count === 0}
|
||||||
|
className="bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{result && !result.dry_run ? "Closed" : result?.count ? `Close ${result.count} calls` : "Close calls"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-950 border border-red-800 rounded-lg p-3">
|
||||||
|
<p className="text-red-400 text-sm font-mono">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
||||||
|
<p className="text-sm font-mono text-white">
|
||||||
|
{result.dry_run ? "Preview: " : "Closed: "}
|
||||||
|
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||||||
|
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
||||||
|
</p>
|
||||||
|
{result.call_ids.length > 0 && (
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||||||
|
{result.call_ids.map((id) => (
|
||||||
|
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main admin page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AdminTab = "features" | "correlation" | "users" | "audit" | "calls";
|
||||||
|
|
||||||
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
|
{ key: "features", label: "AI Features" },
|
||||||
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
|
{ key: "calls", label: "Calls" },
|
||||||
|
{ key: "users", label: "Users" },
|
||||||
|
{ key: "audit", label: "Audit Log" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { user, isAdmin } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tab, setTab] = useState<AdminTab>("features");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) router.replace("/dashboard");
|
||||||
|
}, [isAdmin, router]);
|
||||||
|
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
|
const wide = tab === "users" || tab === "audit";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
|
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||||
|
{TAB_LABELS.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
||||||
|
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "features" && <FeaturesTab />}
|
||||||
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
|
{tab === "audit" && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -18,6 +19,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithEmailAndPassword(auth, email, password);
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid email or password.");
|
setError("Invalid email or password.");
|
||||||
@@ -31,6 +33,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithPopup(auth, new GoogleAuthProvider());
|
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Google sign-in failed. Try again.");
|
setError("Google sign-in failed. Try again.");
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { NodeCard } from "@/components/NodeCard";
|
import { NodeCard } from "@/components/NodeCard";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const { systems } = useSystems();
|
const { systems } = useSystems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||||
|
|
||||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, Fragment } from "react";
|
import { useEffect, useRef, useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||||
|
|
||||||
// ── P25 structured config types ───────────────────────────────────────────────
|
// ── P25 structured config types ───────────────────────────────────────────────
|
||||||
@@ -1178,7 +1180,15 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
// ── Systems list page ─────────────────────────────────────────────────────────
|
// ── Systems list page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SystemsPage() {
|
export default function SystemsPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { systems, loading } = useSystems();
|
const { systems, loading } = useSystems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||||
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface TokenRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TokensPage() {
|
export default function TokensPage() {
|
||||||
const { isAdmin, loading: authLoading } = useAuth();
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,8 +26,8 @@ export default function TokensPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !isAdmin) router.replace("/dashboard");
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
}, [authLoading, isAdmin, router]);
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +67,7 @@ export default function TokensPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading || !isAdmin) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -86,11 +86,14 @@ function TagPill({ tag, availableTags }: { tag: string; availableTags: string[]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectConflicts(events: TripEvent[]): Set<string> {
|
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
|
||||||
const timed = events.filter((e) => e.start_time);
|
const timed = events.filter((e) => e.start_time);
|
||||||
const conflicts = new Set<string>();
|
const conflicts = new Set<string>();
|
||||||
for (let i = 0; i < timed.length; i++) {
|
for (let i = 0; i < timed.length; i++) {
|
||||||
for (let j = i + 1; j < timed.length; j++) {
|
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 aS = toMin(timed[i].start_time!);
|
||||||
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
||||||
const bS = toMin(timed[j].start_time!);
|
const bS = toMin(timed[j].start_time!);
|
||||||
@@ -365,6 +368,7 @@ function DayTimeline({
|
|||||||
onEdit,
|
onEdit,
|
||||||
driveSegments,
|
driveSegments,
|
||||||
availableTags,
|
availableTags,
|
||||||
|
overlapTags,
|
||||||
}: {
|
}: {
|
||||||
events: TripEvent[];
|
events: TripEvent[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -372,12 +376,16 @@ function DayTimeline({
|
|||||||
onEdit: (event: TripEvent) => void;
|
onEdit: (event: TripEvent) => void;
|
||||||
driveSegments: { fromId: string; toId: string; text: string }[];
|
driveSegments: { fromId: string; toId: string; text: string }[];
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
|
overlapTags: string[];
|
||||||
}) {
|
}) {
|
||||||
const timed = [...events.filter((e) => e.start_time)].sort(
|
const timed = [...events.filter((e) => e.start_time)].sort(
|
||||||
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
||||||
);
|
);
|
||||||
const untimed = events.filter((e) => !e.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) {
|
if (timed.length === 0 && untimed.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -423,8 +431,45 @@ function DayTimeline({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Event blocks */}
|
{/* Note events — overlap-allowed, rendered behind as subtle bands */}
|
||||||
{timed.map((e) => {
|
{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 startMin = toMin(e.start_time!);
|
||||||
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
||||||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||||||
@@ -436,7 +481,7 @@ function DayTimeline({
|
|||||||
<div key={e.event_id}>
|
<div key={e.event_id}>
|
||||||
<div
|
<div
|
||||||
style={{ top, height, left: 60, right: 0 }}
|
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
|
isConflict
|
||||||
? "bg-red-950/70 border border-red-700/70"
|
? "bg-red-950/70 border border-red-700/70"
|
||||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
||||||
@@ -495,11 +540,10 @@ function DayTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Drive time badge below this event, if present */}
|
|
||||||
{drive && (
|
{drive && (
|
||||||
<div
|
<div
|
||||||
style={{ top: top + height + 2, left: 60 }}
|
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
|
<span className="text-gray-700">↓</span> {drive} drive
|
||||||
</div>
|
</div>
|
||||||
@@ -864,6 +908,7 @@ export default function TripDetailPage() {
|
|||||||
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
|
||||||
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [inviteInput, setInviteInput] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -965,17 +1010,49 @@ export default function TripDetailPage() {
|
|||||||
if (!trip || !tagInput.trim()) return;
|
if (!trip || !tagInput.trim()) return;
|
||||||
const tag = tagInput.trim();
|
const tag = tagInput.trim();
|
||||||
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
||||||
const updated = [...(trip.available_tags ?? []), tag];
|
const available = [...(trip.available_tags ?? []), tag];
|
||||||
await c2api.updateTripTags(trip.trip_id, updated);
|
const overlap = trip.overlap_tags ?? [];
|
||||||
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||||||
|
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveTag(tag: string) {
|
async function handleRemoveTag(tag: string) {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
const updated = (trip.available_tags ?? []).filter((t) => t !== tag);
|
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
|
||||||
await c2api.updateTripTags(trip.trip_id, updated);
|
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
|
||||||
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
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>;
|
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||||||
@@ -985,7 +1062,7 @@ export default function TripDetailPage() {
|
|||||||
const attendees = Object.values(trip.attendees ?? {});
|
const attendees = Object.values(trip.attendees ?? {});
|
||||||
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
||||||
const hasConflict = (day: string) =>
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1020,7 +1097,13 @@ export default function TripDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1029,6 +1112,12 @@ export default function TripDetailPage() {
|
|||||||
>
|
>
|
||||||
+ Add Event
|
+ Add Event
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleDeleteTrip}
|
onClick={handleDeleteTrip}
|
||||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||||
@@ -1043,14 +1132,26 @@ export default function TripDetailPage() {
|
|||||||
{/* Tag manager */}
|
{/* Tag manager */}
|
||||||
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(trip.available_tags ?? []).map((tag) => (
|
{(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 ?? [])}`}>
|
<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}
|
{tag}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button>
|
<>
|
||||||
|
<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>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -1065,6 +1166,39 @@ export default function TripDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
{/* Two-column layout */}
|
||||||
@@ -1114,6 +1248,7 @@ export default function TripDetailPage() {
|
|||||||
onEdit={setEditEvent}
|
onEdit={setEditEvent}
|
||||||
driveSegments={driveTimes[selectedDay] ?? []}
|
driveSegments={driveTimes[selectedDay] ?? []}
|
||||||
availableTags={trip.available_tags ?? []}
|
availableTags={trip.available_tags ?? []}
|
||||||
|
overlapTags={trip.overlap_tags ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,25 +3,33 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
role: UserRole | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isOperator: boolean;
|
||||||
|
ownedNodeIds: string[];
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
role: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
isOperator: false,
|
||||||
|
ownedNodeIds: [],
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [role, setRole] = useState<UserRole | null>(null);
|
||||||
|
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onAuthStateChanged(auth, async (u) => {
|
return onAuthStateChanged(auth, async (u) => {
|
||||||
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (u) {
|
if (u) {
|
||||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||||
// Read custom claims to determine admin status
|
|
||||||
const result = await u.getIdTokenResult(true);
|
const result = await u.getIdTokenResult(true);
|
||||||
setIsAdmin(!!result.claims.admin);
|
const claims = result.claims;
|
||||||
|
|
||||||
|
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
||||||
|
let effectiveRole: UserRole = "viewer";
|
||||||
|
if (claims.role === "admin" || claims.admin) {
|
||||||
|
effectiveRole = "admin";
|
||||||
|
} else if (claims.role === "operator") {
|
||||||
|
effectiveRole = "operator";
|
||||||
|
} else if (claims.role === "viewer") {
|
||||||
|
effectiveRole = "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
|
setRole(effectiveRole);
|
||||||
|
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
||||||
} else {
|
} else {
|
||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
setIsAdmin(false);
|
setRole(null);
|
||||||
|
setOwnedNodeIds([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const isOperator = role === "operator";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
|
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
const links = [
|
// Links visible to all authenticated roles (viewer+)
|
||||||
|
const viewerLinks = [
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/nodes", label: "Nodes" },
|
|
||||||
{ href: "/systems", label: "Systems" },
|
|
||||||
{ href: "/calls", label: "Calls" },
|
{ href: "/calls", label: "Calls" },
|
||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/map", label: "Map" },
|
||||||
@@ -19,8 +18,15 @@ const links = [
|
|||||||
{ href: "/trips", label: "Trips" },
|
{ href: "/trips", label: "Trips" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
// Additional links for operators and admins
|
||||||
|
const operatorLinks = [
|
||||||
|
{ href: "/nodes", label: "Nodes" },
|
||||||
|
{ href: "/systems", label: "Systems" },
|
||||||
{ href: "/tokens", label: "Tokens" },
|
{ href: "/tokens", label: "Tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Admin-only links
|
||||||
|
const adminLinks = [
|
||||||
{ href: "/admin", label: "Admin" },
|
{ href: "/admin", label: "Admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,8 +55,9 @@ function MoonIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, isOperator } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
const unackedAlerts = useUnacknowledgedAlerts();
|
const unackedAlerts = useUnacknowledgedAlerts();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
@@ -58,7 +65,11 @@ export function Nav() {
|
|||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
const allLinks = [
|
||||||
|
...viewerLinks,
|
||||||
|
...(isAdmin || isOperator ? operatorLinks : []),
|
||||||
|
...(isAdmin ? adminLinks : []),
|
||||||
|
];
|
||||||
|
|
||||||
function navLinkClass(href: string) {
|
function navLinkClass(href: string) {
|
||||||
return `text-sm font-mono transition-colors shrink-0 ${
|
return `text-sm font-mono transition-colors shrink-0 ${
|
||||||
@@ -101,12 +112,17 @@ export function Nav() {
|
|||||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sign out (desktop) */}
|
{/* Profile avatar (desktop) */}
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={() => router.push("/profile")}
|
||||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||||
|
pathname.startsWith("/profile")
|
||||||
|
? "bg-indigo-600 text-white"
|
||||||
|
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
title="Profile"
|
||||||
>
|
>
|
||||||
Sign out
|
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Hamburger (mobile) */}
|
{/* Hamburger (mobile) */}
|
||||||
@@ -154,12 +170,15 @@ export function Nav() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||||
<button
|
<Link
|
||||||
onClick={signOut}
|
href="/profile"
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||||
|
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Sign out
|
Profile
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const c2api = {
|
|||||||
},
|
},
|
||||||
patchTranscript: (callId: string, transcript: string) =>
|
patchTranscript: (callId: string, transcript: string) =>
|
||||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||||
|
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||||
|
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
||||||
|
|
||||||
// Incidents
|
// Incidents
|
||||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||||
@@ -142,8 +144,20 @@ export const c2api = {
|
|||||||
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||||
deleteTrip: (id: string) =>
|
deleteTrip: (id: string) =>
|
||||||
request(`/trips/${id}`, { method: "DELETE" }),
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
updateTripTags: (id: string, available_tags: string[]) =>
|
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
||||||
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
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) =>
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||||
@@ -172,4 +186,34 @@ export const c2api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(flags),
|
body: JSON.stringify(flags),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// User management (admin only)
|
||||||
|
listUsers: () =>
|
||||||
|
request<import("@/lib/types").UserRecord[]>("/admin/users"),
|
||||||
|
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
|
||||||
|
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
getUser: (uid: string) =>
|
||||||
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||||
|
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
||||||
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
disableUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
|
||||||
|
enableUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
|
||||||
|
deleteUser: (uid: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Audit log (admin only)
|
||||||
|
getAuditLog: (limit = 50, offset = 0) =>
|
||||||
|
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
|
||||||
|
|
||||||
|
// Session recording — called on each explicit sign-in
|
||||||
|
recordSession: () =>
|
||||||
|
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||||
|
export type UserRole = "admin" | "operator" | "viewer";
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
uid: string;
|
||||||
|
email: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
owned_node_ids: string[];
|
||||||
|
disabled: boolean;
|
||||||
|
creation_time: string | null;
|
||||||
|
last_sign_in: string | null;
|
||||||
|
discord_linked: boolean;
|
||||||
|
discord_username: string | null;
|
||||||
|
discord_user_id: string | null;
|
||||||
|
// only present on GET /admin/users/{uid}
|
||||||
|
sessions?: UserSession[];
|
||||||
|
// only present on POST /admin/users response
|
||||||
|
invite_link?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
session_id: string;
|
||||||
|
uid: string;
|
||||||
|
email: string;
|
||||||
|
timestamp: string;
|
||||||
|
ip: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
log_id: string;
|
||||||
|
action: string;
|
||||||
|
actor_uid: string;
|
||||||
|
actor_email: string;
|
||||||
|
target_uid: string | null;
|
||||||
|
target_email: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeRecord {
|
export interface NodeRecord {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
@@ -134,6 +173,9 @@ export interface TripRecord {
|
|||||||
end_date: string;
|
end_date: string;
|
||||||
attendees: Record<string, string>;
|
attendees: Record<string, string>;
|
||||||
available_tags: string[];
|
available_tags: string[];
|
||||||
|
overlap_tags: string[];
|
||||||
|
visibility: "public" | "private";
|
||||||
|
invited_discord_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
events?: TripEvent[];
|
events?: TripEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ def _date_range(start_iso: str, end_iso: str):
|
|||||||
# Cog
|
# Cog
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
|
||||||
|
if trip.get("visibility", "public") == "public":
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("attendees", {}):
|
||||||
|
return True
|
||||||
|
if discord_user_id in trip.get("invited_discord_ids", []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TripCommands(commands.Cog):
|
class TripCommands(commands.Cog):
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
@@ -79,10 +89,11 @@ class TripCommands(commands.Cog):
|
|||||||
self, interaction: discord.Interaction, current: str
|
self, interaction: discord.Interaction, current: str
|
||||||
) -> list[app_commands.Choice[str]]:
|
) -> list[app_commands.Choice[str]]:
|
||||||
trips = await c2.get_trips()
|
trips = await c2.get_trips()
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
return [
|
return [
|
||||||
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
||||||
for t in trips
|
for t in trips
|
||||||
if current.lower() in t["name"].lower()
|
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
|
||||||
][:25]
|
][:25]
|
||||||
|
|
||||||
async def event_autocomplete(
|
async def event_autocomplete(
|
||||||
@@ -172,6 +183,9 @@ class TripCommands(commands.Cog):
|
|||||||
today = date.today().strftime("%Y-%m-%d")
|
today = date.today().strftime("%Y-%m-%d")
|
||||||
trips.sort(key=lambda t: t.get("start_date", ""))
|
trips.sort(key=lambda t: t.get("start_date", ""))
|
||||||
|
|
||||||
|
user_id = str(interaction.user.id)
|
||||||
|
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
|
||||||
|
|
||||||
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
||||||
for t in trips[:10]:
|
for t in trips[:10]:
|
||||||
upcoming = t.get("start_date", "") >= today
|
upcoming = t.get("start_date", "") >= today
|
||||||
@@ -181,8 +195,9 @@ class TripCommands(commands.Cog):
|
|||||||
f"{_fmt_date(t.get('end_date', ''))}"
|
f"{_fmt_date(t.get('end_date', ''))}"
|
||||||
)
|
)
|
||||||
attendee_count = len(t.get("attendees", {}))
|
attendee_count = len(t.get("attendees", {}))
|
||||||
|
field_name = f"{t['name']} [{status}]"[:256]
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"{t['name']} [{status}]",
|
name=field_name,
|
||||||
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
|
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
|
||||||
inline=False,
|
inline=False,
|
||||||
)
|
)
|
||||||
@@ -203,6 +218,9 @@ class TripCommands(commands.Cog):
|
|||||||
if not data:
|
if not data:
|
||||||
await interaction.followup.send("Trip not found.")
|
await interaction.followup.send("Trip not found.")
|
||||||
return
|
return
|
||||||
|
if not _user_can_see_trip(data, str(interaction.user.id)):
|
||||||
|
await interaction.followup.send("This trip is private.", ephemeral=True)
|
||||||
|
return
|
||||||
|
|
||||||
attendee_names = list(data.get("attendees", {}).values())
|
attendee_names = list(data.get("attendees", {}).values())
|
||||||
desc_lines = [
|
desc_lines = [
|
||||||
@@ -215,8 +233,8 @@ class TripCommands(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=data["name"],
|
title=data["name"][:256],
|
||||||
description="\n".join(desc_lines),
|
description="\n".join(desc_lines)[:4096],
|
||||||
color=0x5865f2,
|
color=0x5865f2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,19 +243,21 @@ class TripCommands(commands.Cog):
|
|||||||
for e in data.get("events", []):
|
for e in data.get("events", []):
|
||||||
events_by_date.setdefault(e["date"], []).append(e)
|
events_by_date.setdefault(e["date"], []).append(e)
|
||||||
|
|
||||||
|
# Track total embed chars (Discord limit: 6000)
|
||||||
|
embed_chars = len(embed.title or "") + len(embed.description or "")
|
||||||
field_count = 0
|
field_count = 0
|
||||||
for day_iso in _date_range(data["start_date"], data["end_date"]):
|
for day_iso in _date_range(data["start_date"], data["end_date"]):
|
||||||
day_events = events_by_date.get(day_iso)
|
day_events = events_by_date.get(day_iso)
|
||||||
if not day_events:
|
if not day_events:
|
||||||
continue
|
continue
|
||||||
if field_count >= 24:
|
if field_count >= 24 or embed_chars >= 5800:
|
||||||
embed.add_field(name="...", value="More events not shown.", inline=False)
|
embed.add_field(name="...", value="More events not shown.", inline=False)
|
||||||
break
|
break
|
||||||
|
|
||||||
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
|
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
|
||||||
lines = []
|
lines = []
|
||||||
for e in sorted(day_events, key=lambda x: x.get("time") or ""):
|
for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
|
||||||
time_str = _fmt_time(e.get("time"))
|
time_str = _fmt_time(e.get("start_time"))
|
||||||
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
|
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
|
||||||
|
|
||||||
loc = e.get("location")
|
loc = e.get("location")
|
||||||
@@ -258,7 +278,12 @@ class TripCommands(commands.Cog):
|
|||||||
|
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
|
|
||||||
embed.add_field(name=f"— {day_label} —", value="\n".join(lines), inline=False)
|
field_name = f"— {day_label} —"
|
||||||
|
field_value = "\n".join(lines)
|
||||||
|
if len(field_value) > 1024:
|
||||||
|
field_value = field_value[:1021] + "…"
|
||||||
|
embed.add_field(name=field_name, value=field_value, inline=False)
|
||||||
|
embed_chars += len(field_name) + len(field_value)
|
||||||
field_count += 1
|
field_count += 1
|
||||||
|
|
||||||
if not events_by_date:
|
if not events_by_date:
|
||||||
@@ -294,9 +319,11 @@ class TripCommands(commands.Cog):
|
|||||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
ok = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
||||||
if ok:
|
if result is True:
|
||||||
await interaction.followup.send("You're on the trip!")
|
await interaction.followup.send("You're on the trip!")
|
||||||
|
elif result == "private":
|
||||||
|
await interaction.followup.send("This trip is private — you need an invite to join.")
|
||||||
else:
|
else:
|
||||||
await interaction.followup.send("Failed to join trip.")
|
await interaction.followup.send("Failed to join trip.")
|
||||||
|
|
||||||
@@ -425,5 +452,64 @@ class TripCommands(commands.Cog):
|
|||||||
await interaction.followup.send("Failed to leave event.")
|
await interaction.followup.send("Failed to leave event.")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip invite
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
|
||||||
|
@app_commands.describe(trip="The trip.", user="The user to invite.")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.invite_to_trip(trip, str(user.id))
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to send invite.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /trip privacy
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@trip_group.command(name="privacy", description="Set a trip to public or private.")
|
||||||
|
@app_commands.describe(trip="The trip.", visibility="public or private")
|
||||||
|
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||||
|
@app_commands.choices(visibility=[
|
||||||
|
app_commands.Choice(name="Public — anyone can see and join", value="public"),
|
||||||
|
app_commands.Choice(name="Private — invite only", value="private"),
|
||||||
|
])
|
||||||
|
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
ok = await c2.set_trip_visibility(trip, visibility)
|
||||||
|
if ok:
|
||||||
|
await interaction.followup.send(f"Trip is now **{visibility}**.")
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Failed to update trip privacy.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# /link
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
|
||||||
|
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
|
||||||
|
async def link_account(self, interaction: discord.Interaction, code: str):
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
result = await c2.link_discord_account(
|
||||||
|
code.upper().strip(),
|
||||||
|
str(interaction.user.id),
|
||||||
|
interaction.user.display_name,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
msgs = {
|
||||||
|
"invalid_code": "Invalid code. Generate a new one from the web app.",
|
||||||
|
"expired": "Code has expired. Generate a new one from the web app.",
|
||||||
|
"already_linked": "This Discord account is already linked to a different web account.",
|
||||||
|
"failed": "Something went wrong. Try again.",
|
||||||
|
}
|
||||||
|
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
|
||||||
|
else:
|
||||||
|
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot):
|
async def setup(bot: commands.Bot):
|
||||||
await bot.add_cog(TripCommands(bot))
|
await bot.add_cog(TripCommands(bot))
|
||||||
|
|||||||
@@ -112,7 +112,55 @@ class C2Client:
|
|||||||
logger.error(f"C2 delete_trip failed: {e}")
|
logger.error(f"C2 delete_trip failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool:
|
async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 invite_to_trip failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.put(
|
||||||
|
f"{self.base}/trips/{trip_id}/visibility",
|
||||||
|
json={"visibility": visibility},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 set_trip_visibility failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{self.base}/auth/link",
|
||||||
|
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
|
||||||
|
headers=self._headers(),
|
||||||
|
)
|
||||||
|
if r.status_code == 404:
|
||||||
|
return {"error": "invalid_code"}
|
||||||
|
if r.status_code == 410:
|
||||||
|
return {"error": "expired"}
|
||||||
|
if r.status_code == 409:
|
||||||
|
return {"error": "already_linked"}
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"C2 link_discord_account failed: {e}")
|
||||||
|
return {"error": "failed"}
|
||||||
|
|
||||||
|
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
|
||||||
|
"""Returns True on success, 'private' on 403, False on other errors."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
@@ -120,6 +168,8 @@ class C2Client:
|
|||||||
json={"discord_user_id": user_id, "discord_username": username},
|
json={"discord_user_id": user_id, "discord_username": username},
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
)
|
)
|
||||||
|
if r.status_code == 403:
|
||||||
|
return "private"
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
|
||||||
|
# Caddy handles TLS automatically via Let's Encrypt.
|
||||||
|
|
||||||
|
api.{$DRB_DOMAIN} {
|
||||||
|
reverse_proxy localhost:8888 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.{$DRB_DOMAIN} {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
|
||||||
|
|
||||||
|
ANSIBLE_DIR = ansible
|
||||||
|
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
|
||||||
|
|
||||||
|
# ── Terraform ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tf-init:
|
||||||
|
terraform init
|
||||||
|
|
||||||
|
tf-plan:
|
||||||
|
terraform plan
|
||||||
|
|
||||||
|
tf-apply:
|
||||||
|
terraform apply
|
||||||
|
@echo ""
|
||||||
|
@echo "Server IP: $$(terraform output -raw server_ip)"
|
||||||
|
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
|
||||||
|
|
||||||
|
tf-destroy:
|
||||||
|
@echo "WARNING: This will destroy the VM and all data on it."
|
||||||
|
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
|
||||||
|
|
||||||
|
# ── Ansible ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# First-time setup: waits for Docker, clones repo, starts stack.
|
||||||
|
setup:
|
||||||
|
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
|
||||||
|
|
||||||
|
# Update deploy: sync code + restart changed containers. Run this after every push.
|
||||||
|
deploy:
|
||||||
|
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ip:
|
||||||
|
@terraform output -raw server_ip
|
||||||
|
|
||||||
|
ssh:
|
||||||
|
ssh drb@$$(terraform output -raw server_ip)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
# Lightweight update deploy — runs in ~60s.
|
||||||
|
# Use this for every code push after the initial site.yml run.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
|
||||||
|
|
||||||
|
- name: Deploy DRB update
|
||||||
|
hosts: drb
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- vault.yml
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- deploy
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Copy to group_vars/all.yml — safe to commit (no secrets here).
|
||||||
|
|
||||||
|
domain: example.com # must match Terraform var.domain
|
||||||
|
app_dir: /opt/drb
|
||||||
|
ssh_user: drb
|
||||||
|
|
||||||
|
# Path to the local repo root on your machine (used for rsync).
|
||||||
|
# Trailing slash is intentional — rsync copies contents, not the folder itself.
|
||||||
|
local_repo_path: "/path/to/Version 5C/Server/"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
|
||||||
|
# Get it with: cd ../terraform && terraform output server_ip
|
||||||
|
|
||||||
|
[drb]
|
||||||
|
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
[drb:vars]
|
||||||
|
ansible_python_interpreter=/usr/bin/python3
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
|
||||||
|
# Images are built and pushed by Gitea CI — this role never builds on the VM.
|
||||||
|
|
||||||
|
- name: Clone repo (skipped if already present)
|
||||||
|
git:
|
||||||
|
repo: "{{ repo_url }}"
|
||||||
|
dest: "{{ app_dir }}"
|
||||||
|
version: main
|
||||||
|
update: false
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Set ownership of app directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Template top-level .env (docker-compose MQTT creds + registry)
|
||||||
|
template:
|
||||||
|
src: root.env.j2
|
||||||
|
dest: "{{ app_dir }}/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template c2-core .env
|
||||||
|
template:
|
||||||
|
src: c2-core.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-c2-core/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template discord-bot .env
|
||||||
|
template:
|
||||||
|
src: discord-bot.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template frontend .env
|
||||||
|
template:
|
||||||
|
src: frontend.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-frontend/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Deploy Caddyfile
|
||||||
|
template:
|
||||||
|
src: Caddyfile.j2
|
||||||
|
dest: /etc/caddy/Caddyfile
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload Caddy
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
command: >
|
||||||
|
docker login {{ vault_registry_host }}
|
||||||
|
-u {{ vault_registry_user }}
|
||||||
|
-p {{ vault_registry_token }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Pull pre-built images and start stack
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
files:
|
||||||
|
- docker-compose.yml
|
||||||
|
- docker-compose.prod.yml
|
||||||
|
pull: always
|
||||||
|
build: never
|
||||||
|
state: present
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Managed by Ansible — do not edit manually on the server.
|
||||||
|
|
||||||
|
api.{{ domain }} {
|
||||||
|
reverse_proxy localhost:8888 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.{{ domain }} {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
MQTT_BROKER=mosquitto
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER={{ vault_mqtt_c2_user }}
|
||||||
|
MQTT_PASS={{ vault_mqtt_c2_pass }}
|
||||||
|
|
||||||
|
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||||
|
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||||
|
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||||
|
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||||
|
|
||||||
|
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||||
|
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||||
|
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||||
|
|
||||||
|
SERVICE_KEY={{ vault_service_key }}
|
||||||
|
NODE_API_KEY={{ vault_node_api_key }}
|
||||||
|
|
||||||
|
CORS_ORIGINS=["https://app.{{ domain }}"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
DISCORD_TOKEN={{ vault_discord_token }}
|
||||||
|
C2_URL=http://c2-core:8000
|
||||||
|
C2_SERVICE_KEY={{ vault_service_key }}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# drb-frontend environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
|
||||||
|
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
||||||
|
# Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
MQTT_C2_USER={{ vault_mqtt_c2_user }}
|
||||||
|
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
|
||||||
|
MQTT_NODE_USER={{ vault_mqtt_node_user }}
|
||||||
|
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
|
||||||
|
|
||||||
|
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
|
||||||
|
REGISTRY={{ vault_registry }}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
# Full first-time setup: waits for the VM's startup.sh to finish installing
|
||||||
|
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
|
||||||
|
|
||||||
|
- name: Bootstrap + deploy DRB server
|
||||||
|
hosts: drb
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- vault.yml
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Install rsync
|
||||||
|
apt:
|
||||||
|
name: rsync
|
||||||
|
state: present
|
||||||
|
update_cache: false
|
||||||
|
|
||||||
|
- name: Wait for Docker (startup.sh runs async on first boot)
|
||||||
|
command: docker info
|
||||||
|
register: _docker
|
||||||
|
until: _docker.rc == 0
|
||||||
|
retries: 30
|
||||||
|
delay: 10
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create 2 GB swap file
|
||||||
|
command: fallocate -l 2G /swapfile
|
||||||
|
args:
|
||||||
|
creates: /swapfile
|
||||||
|
|
||||||
|
- name: Set swap file permissions
|
||||||
|
file:
|
||||||
|
path: /swapfile
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Format swap file
|
||||||
|
command: mkswap /swapfile
|
||||||
|
register: _mkswap
|
||||||
|
changed_when: _mkswap.rc == 0
|
||||||
|
|
||||||
|
- name: Enable swap
|
||||||
|
command: swapon /swapfile
|
||||||
|
register: _swapon
|
||||||
|
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
|
||||||
|
changed_when: _swapon.rc == 0
|
||||||
|
|
||||||
|
- name: Persist swap in fstab
|
||||||
|
lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "/swapfile none swap sw 0 0"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Set swappiness to 10 (use swap only under pressure)
|
||||||
|
sysctl:
|
||||||
|
name: vm.swappiness
|
||||||
|
value: "10"
|
||||||
|
sysctl_set: true
|
||||||
|
state: present
|
||||||
|
reload: true
|
||||||
|
|
||||||
|
- name: Add deploy user to docker group
|
||||||
|
user:
|
||||||
|
name: "{{ ssh_user }}"
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
|
||||||
|
- name: Create app directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- deploy
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Template for your Ansible Vault secrets file.
|
||||||
|
# Copy to vault.yml, fill in values, then encrypt:
|
||||||
|
# ansible-vault encrypt vault.yml
|
||||||
|
# Edit later with:
|
||||||
|
# ansible-vault edit vault.yml
|
||||||
|
|
||||||
|
# ── MQTT ─────────────────────────────────────────────────────────────────────
|
||||||
|
vault_mqtt_c2_user: drb-c2-core
|
||||||
|
vault_mqtt_c2_pass: "CHANGE_ME"
|
||||||
|
vault_mqtt_node_user: drb-node
|
||||||
|
vault_mqtt_node_pass: "CHANGE_ME"
|
||||||
|
|
||||||
|
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||||
|
vault_service_key: "" # openssl rand -hex 32
|
||||||
|
vault_node_api_key: "" # openssl rand -hex 32
|
||||||
|
vault_openai_api_key: ""
|
||||||
|
vault_google_maps_api_key: ""
|
||||||
|
vault_gemini_api_key: ""
|
||||||
|
vault_gcs_bucket: "your-gcs-bucket-name"
|
||||||
|
vault_firestore_database: "c2-server"
|
||||||
|
|
||||||
|
# ── Gitea Container Registry ──────────────────────────────────────────────────
|
||||||
|
vault_registry_host: "git.vpn.cusano.net"
|
||||||
|
vault_registry_user: "logan"
|
||||||
|
vault_registry_token: "" # Gitea access token with package:write scope
|
||||||
|
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
|
||||||
|
|
||||||
|
# ── Discord Bot ───────────────────────────────────────────────────────────────
|
||||||
|
vault_discord_token: ""
|
||||||
|
|
||||||
|
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
|
||||||
|
vault_firebase_api_key: ""
|
||||||
|
vault_firebase_auth_domain: ""
|
||||||
|
vault_firebase_project_id: ""
|
||||||
|
vault_firebase_storage_bucket: ""
|
||||||
|
vault_firebase_messaging_sender_id: ""
|
||||||
|
vault_firebase_app_id: ""
|
||||||
|
|
||||||
|
# No GCP key needed — the VM uses Application Default Credentials via the
|
||||||
|
# GCE metadata server. Terraform grants the required IAM roles at apply time.
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.6"
|
||||||
|
required_providers {
|
||||||
|
google = {
|
||||||
|
source = "hashicorp/google"
|
||||||
|
version = "~> 5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store state in GCS — create the bucket manually once before first apply
|
||||||
|
# Uncomment once GCS bucket permissions are confirmed working.
|
||||||
|
# backend "gcs" {
|
||||||
|
# bucket = "drb-tf-state"
|
||||||
|
# prefix = "drb/state"
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "google" {
|
||||||
|
project = var.project_id
|
||||||
|
region = var.region
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pull live project metadata (number, name) without hardcoding them.
|
||||||
|
data "google_project" "current" {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static external IP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "google_compute_address" "drb" {
|
||||||
|
name = "drb-server-ip"
|
||||||
|
region = var.region
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firewall rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "google_compute_firewall" "allow_web" {
|
||||||
|
name = "drb-allow-web"
|
||||||
|
network = "default"
|
||||||
|
|
||||||
|
allow {
|
||||||
|
protocol = "tcp"
|
||||||
|
ports = ["80", "443"]
|
||||||
|
}
|
||||||
|
|
||||||
|
source_ranges = ["0.0.0.0/0"]
|
||||||
|
target_tags = ["drb-server"]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_compute_firewall" "allow_ssh" {
|
||||||
|
name = "drb-allow-ssh"
|
||||||
|
network = "default"
|
||||||
|
|
||||||
|
allow {
|
||||||
|
protocol = "tcp"
|
||||||
|
ports = ["22"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restrict SSH to your IP(s) and Gitea runner IP
|
||||||
|
source_ranges = var.allowed_ssh_cidrs
|
||||||
|
target_tags = ["drb-server"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
|
||||||
|
# If you need to temporarily allow direct MQTT access for testing, uncomment and
|
||||||
|
# restrict source_ranges to your node IPs.
|
||||||
|
#
|
||||||
|
# resource "google_compute_firewall" "allow_mqtt" {
|
||||||
|
# name = "drb-allow-mqtt"
|
||||||
|
# network = "default"
|
||||||
|
# allow {
|
||||||
|
# protocol = "tcp"
|
||||||
|
# ports = ["8883"] # TLS MQTT, not 1883
|
||||||
|
# }
|
||||||
|
# source_ranges = ["YOUR_NODE_CIDR"]
|
||||||
|
# target_tags = ["drb-server"]
|
||||||
|
# }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compute Engine VM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "google_compute_instance" "drb_server" {
|
||||||
|
name = "drb-server"
|
||||||
|
machine_type = var.machine_type
|
||||||
|
zone = var.zone
|
||||||
|
tags = ["drb-server"]
|
||||||
|
|
||||||
|
boot_disk {
|
||||||
|
initialize_params {
|
||||||
|
image = "debian-cloud/debian-12"
|
||||||
|
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
|
||||||
|
type = "pd-standard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
network_interface {
|
||||||
|
network = "default"
|
||||||
|
access_config {
|
||||||
|
nat_ip = google_compute_address.drb.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Startup script runs once on first boot to install Docker + Caddy
|
||||||
|
metadata_startup_script = file("${path.module}/startup.sh")
|
||||||
|
|
||||||
|
# The default compute service account with cloud-platform scope gives the VM
|
||||||
|
# full access to GCS and Firestore in the same project — no key file needed.
|
||||||
|
service_account {
|
||||||
|
scopes = ["cloud-platform"]
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
# Prevent Terraform from destroying + recreating on metadata changes
|
||||||
|
ignore_changes = [metadata_startup_script]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IAM — grant the VM's default compute SA access to Firestore and GCS.
|
||||||
|
# Since Firebase/GCS already live in the same project, no key file is needed —
|
||||||
|
# the VM authenticates via the metadata server (ADC).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
locals {
|
||||||
|
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_project_iam_member" "drb_firestore" {
|
||||||
|
project = var.project_id
|
||||||
|
role = "roles/datastore.user"
|
||||||
|
member = local.compute_sa
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_project_iam_member" "drb_gcs" {
|
||||||
|
project = var.project_id
|
||||||
|
role = "roles/storage.objectAdmin"
|
||||||
|
member = local.compute_sa
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firestore database — import existing, manages schema/settings going forward
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
id = "projects/${var.project_id}/databases/${var.firestore_database}"
|
||||||
|
to = google_firestore_database.c2
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firestore_database" "c2" {
|
||||||
|
project = var.project_id
|
||||||
|
name = var.firestore_database
|
||||||
|
location_id = var.firestore_location
|
||||||
|
type = "FIRESTORE_NATIVE"
|
||||||
|
|
||||||
|
# Prevent accidental deletion of the live database
|
||||||
|
deletion_policy = "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GCS bucket — audio recordings. Import existing bucket.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
id = var.audio_bucket_name
|
||||||
|
to = google_storage_bucket.audio
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_storage_bucket" "audio" {
|
||||||
|
project = var.project_id
|
||||||
|
name = var.audio_bucket_name
|
||||||
|
location = var.audio_bucket_location
|
||||||
|
uniform_bucket_level_access = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DNS — managed in AWS Route 53 (cusano.net is there).
|
||||||
|
# After terraform apply, add these A records in Route 53:
|
||||||
|
# app.drb.cusano.net → server_ip output
|
||||||
|
# api.drb.cusano.net → server_ip output
|
||||||
|
# Or use a single wildcard: *.drb.cusano.net → server_ip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
output "server_ip" {
|
||||||
|
value = google_compute_address.drb.address
|
||||||
|
description = "Static external IP of the DRB server VM"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_url" {
|
||||||
|
value = "https://app.${var.domain}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "api_url" {
|
||||||
|
value = "https://api.${var.domain}"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "project_number" {
|
||||||
|
value = data.google_project.current.number
|
||||||
|
description = "GCP project number (useful for service account references)"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ssh_command" {
|
||||||
|
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
|
||||||
|
description = "SSH command to reach the server (should rarely be needed)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy.
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||||
|
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||||
|
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
|
||||||
|
systemctl enable docker
|
||||||
|
systemctl start docker
|
||||||
|
|
||||||
|
# Allow drb user to run docker
|
||||||
|
usermod -aG docker drb 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Caddy (reverse proxy + auto TLS) ─────────────────────────────────────────
|
||||||
|
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||||
|
> /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y caddy
|
||||||
|
|
||||||
|
# ── App directory — clone repo so CI can git pull + docker compose up ─────────
|
||||||
|
apt-get install -y git
|
||||||
|
mkdir -p /opt/drb
|
||||||
|
# Repo is cloned here by initial setup; CI just git pulls and rebuilds.
|
||||||
|
# Set safe directory for the drb user
|
||||||
|
git config --global --add safe.directory /opt/drb
|
||||||
|
chown -R drb:drb /opt/drb 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Caddyfile placeholder (CI will write the real one on first deploy) ────────
|
||||||
|
cat > /etc/caddy/Caddyfile <<'CADDY'
|
||||||
|
# This file is managed by CI. Do not edit manually.
|
||||||
|
# It will be replaced on the first deployment.
|
||||||
|
:80 {
|
||||||
|
respond "DRB server — waiting for deployment" 200
|
||||||
|
}
|
||||||
|
CADDY
|
||||||
|
|
||||||
|
systemctl enable caddy
|
||||||
|
systemctl reload caddy
|
||||||
|
|
||||||
|
echo "Startup complete."
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Copy to terraform.tfvars and fill in values.
|
||||||
|
# terraform.tfvars is gitignored — never commit it.
|
||||||
|
|
||||||
|
project_id = "your-gcp-project-id" # gcloud config get-value project
|
||||||
|
region = "us-central1"
|
||||||
|
zone = "us-central1-a"
|
||||||
|
|
||||||
|
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
|
||||||
|
|
||||||
|
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
|
||||||
|
|
||||||
|
ssh_user = "drb"
|
||||||
|
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# Your IP + any CI runner IPs that need SSH access
|
||||||
|
allowed_ssh_cidrs = ["YOUR_IP/32"]
|
||||||
|
|
||||||
|
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
|
||||||
|
audio_bucket_name = "your-audio-bucket-name"
|
||||||
|
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
|
||||||
|
|
||||||
|
# Existing Firestore database ID and location (imported into state)
|
||||||
|
firestore_database = "c2-server"
|
||||||
|
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
variable "project_id" {
|
||||||
|
description = "GCP project ID"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "region" {
|
||||||
|
description = "GCP region"
|
||||||
|
type = string
|
||||||
|
default = "us-central1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "zone" {
|
||||||
|
description = "GCP zone"
|
||||||
|
type = string
|
||||||
|
default = "us-central1-a"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain" {
|
||||||
|
description = "Base domain (e.g. example.com)"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "machine_type" {
|
||||||
|
description = "Compute Engine machine type"
|
||||||
|
type = string
|
||||||
|
default = "e2-small"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ssh_user" {
|
||||||
|
description = "SSH username for the VM"
|
||||||
|
type = string
|
||||||
|
default = "drb"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "ssh_public_key" {
|
||||||
|
description = "SSH public key to authorize on the VM"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "allowed_ssh_cidrs" {
|
||||||
|
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "audio_bucket_name" {
|
||||||
|
description = "Existing GCS bucket name for call audio recordings"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "audio_bucket_location" {
|
||||||
|
description = "GCS bucket location — must match the existing bucket's location exactly"
|
||||||
|
type = string
|
||||||
|
default = "US-CENTRAL1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "firestore_database" {
|
||||||
|
description = "Firestore database ID (e.g. c2-server)"
|
||||||
|
type = string
|
||||||
|
default = "c2-server"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "firestore_location" {
|
||||||
|
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
|
||||||
|
type = string
|
||||||
|
default = "nam5"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user