Add Phase 3: Clerk auth with org-scoped data isolation

Backend: JWT middleware validates Clerk tokens on every request,
extracts org ID from claims, enforces org-scoped queries via
Supabase RLS. Frontend: ClerkProvider wraps the app, auth gate
blocks unauthenticated access, UserButton in header, token
injected into every API call. Supabase production wired to trust
Clerk JWTs via Third-Party Auth integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-05-29 12:12:17 -04:00
parent 35a61e11d5
commit ec2cd24bd7
11 changed files with 445 additions and 85 deletions

View file

@ -0,0 +1,187 @@
# Phase 3 Setup Checklist
## Clerk + Supabase + Railway Staging
Run these steps in order. Code is already written — you just need the keys.
---
## Step 1: Clerk Account Setup
1. Go to clerk.com and create an account
2. Create a new application — name it **Signal**
3. Select **React** as the framework
4. From **Dashboard > API Keys**, copy:
- **Publishable Key** (starts with `pk_test_...`) — goes in the frontend `.env`
- **Secret Key** (starts with `sk_test_...`) — NOT needed for our approach (we use JWKS)
5. From **Dashboard > API Keys > Show API URLs**, copy the **Frontend API URL**
- Looks like: `https://clean-mayfly-62.clerk.accounts.dev`
- Your JWKS URL is: `<Frontend API URL>/.well-known/jwks.json`
- Example: `https://clean-mayfly-62.clerk.accounts.dev/.well-known/jwks.json`
- This goes in Railway as `CLERK_JWKS_URL`
6. From **Dashboard > Configure > Organizations**, enable Organizations
- Create one organization named **Gaboro DME** with slug `gaboro-pilot`
- Copy the Organization ID (starts with `org_...`) — you'll need it for the SQL step below
- Add yourself as a member of that organization
7. From **Dashboard > Configure > Domains**, add:
- `http://localhost:5173` (local dev)
- Your Vercel production URL when it's live
---
## Step 2: Supabase Staging Project
1. Go to supabase.com, create a new project named **signal-staging**
2. Wait for it to provision (2-3 minutes)
3. Go to **Settings > API**, copy:
- **Project URL** (looks like `https://xxxxxxxxxxxx.supabase.co`)
- **Service Role Key** — must be the long `eyJ...` JWT format (NOT the `sb_` format)
4. Go to **SQL Editor**, run the migration SQL below
---
## Step 3: SQL Migration
### Run on BOTH Supabase projects (production + staging)
```sql
-- Add Clerk org ID column to organizations table
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS clerk_org_id TEXT UNIQUE;
-- Update the pilot org with the real Clerk org ID
-- Replace org_xxxxxxxxxxxxxxxx with the actual org ID from Clerk Dashboard
UPDATE organizations
SET clerk_org_id = 'org_xxxxxxxxxxxxxxxx'
WHERE slug = 'gaboro-pilot';
-- Helper function for RLS policies (reads Clerk org ID from JWT)
CREATE OR REPLACE FUNCTION requesting_owner_id()
RETURNS TEXT
LANGUAGE SQL
STABLE
AS $$
SELECT COALESCE(
(auth.jwt() -> 'o') ->> 'id',
auth.jwt() ->> 'sub'
);
$$;
-- RLS on organizations
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_see_own_org"
ON organizations FOR SELECT
USING (clerk_org_id = requesting_owner_id());
-- RLS on upload_batches
ALTER TABLE upload_batches ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_see_own_batches"
ON upload_batches FOR SELECT
USING (org_id IN (
SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id()
));
CREATE POLICY "org_members_insert_own_batches"
ON upload_batches FOR INSERT
WITH CHECK (org_id IN (
SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id()
));
-- RLS on normalized_records
ALTER TABLE normalized_records ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_see_own_records"
ON normalized_records FOR SELECT
USING (EXISTS (
SELECT 1 FROM upload_batches ub
JOIN organizations o ON o.id = ub.org_id
WHERE ub.id = normalized_records.batch_id
AND o.clerk_org_id = requesting_owner_id()
));
-- RLS on report_runs
ALTER TABLE report_runs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_see_own_runs"
ON report_runs FOR SELECT
USING (org_id IN (
SELECT id FROM organizations WHERE clerk_org_id = requesting_owner_id()
));
-- RLS on export_files
ALTER TABLE export_files ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_see_own_exports"
ON export_files FOR SELECT
USING (EXISTS (
SELECT 1 FROM report_runs rr
JOIN organizations o ON o.id = rr.org_id
WHERE rr.id = export_files.report_run_id
AND o.clerk_org_id = requesting_owner_id()
));
-- NOTE: source_files and mapping_decisions are write-once audit records.
-- The backend uses the service role key for writes (bypasses RLS by design).
-- Add SELECT policies here when frontend reads are needed in a later phase.
```
---
## Step 4: Connect Supabase to Clerk (Native Integration)
### In Clerk Dashboard:
1. Go to **Configure > Integrations** (or search "Supabase")
2. Click **Activate Supabase integration**
3. Copy your **Clerk domain** shown there
### In Supabase Dashboard (repeat for BOTH projects):
1. Go to **Authentication > Sign In / Sign Up > Third Party Auth**
2. Click **Add provider** > select **Clerk**
3. Paste the Clerk domain
4. Save
This makes Supabase trust Clerk JWTs — no custom templates needed.
---
## Step 5: Set Railway Environment Variables
### Production environment:
In Railway > signal-api > production > Variables, add:
```
CLERK_JWKS_URL = https://eternal-goblin-1.clerk.accounts.dev/.well-known/jwks.json
```
### Staging environment:
In Railway > signal-api > staging > Variables, add:
```
CLERK_JWKS_URL = https://eternal-goblin-1.clerk.accounts.dev/.well-known/jwks.json
SUPABASE_URL = https://<staging-project>.supabase.co
SUPABASE_ANON_KEY = eyJ... (anon key from staging project)
SUPABASE_SERVICE_KEY = eyJ... (service role key from staging project)
```
Staging already has SIGNAL_API_KEY and ALLOWED_ORIGINS set.
---
## Step 6: Set Frontend .env
Edit `signal-ui/.env`:
```
VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxx
```
---
## Step 7: Deploy Staging Backend
Once all Railway staging vars are set, trigger a deploy:
```bash
cd /Users/sttil-solutions/projects/signal
railway up --detach --environment staging -m "Phase 3: Clerk auth + staging env"
```
---
## Staging API Key (save this)
The staging environment uses a separate API key for direct testing:
```
r0C6vm1RLeg84lB9jeZ5sSRieDZWsLL0dAMDfEcXubE
```

View file

@ -5,9 +5,12 @@ import io
import os import os
import sys import sys
from datetime import date from datetime import date
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import jwt
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -49,11 +52,54 @@ app.add_middleware(
# API key auth — enforced when SIGNAL_API_KEY env var is set. # API key auth — enforced when SIGNAL_API_KEY env var is set.
# In dev (no env var), all requests pass. In production, X-API-Key header is required. # In dev (no env var), all requests pass. In production, X-API-Key header is required.
_api_key = os.getenv("SIGNAL_API_KEY", "") _api_key = os.getenv("SIGNAL_API_KEY", "")
_clerk_jwks_url = os.getenv("CLERK_JWKS_URL", "")
def _require_api_key(x_api_key: str = Header(default="")) -> None: @lru_cache(maxsize=1)
if _api_key and x_api_key != _api_key: def _get_jwks_client() -> PyJWKClient:
raise HTTPException(status_code=401, detail="Invalid or missing API key") if not _clerk_jwks_url:
raise RuntimeError("CLERK_JWKS_URL is not set")
return PyJWKClient(_clerk_jwks_url, cache_keys=True, cache_jwk_set=True, lifespan=300)
def verify_clerk_token(authorization: str) -> dict:
"""Verify a Clerk Bearer JWT. Returns decoded claims or raises HTTP 401."""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
token = authorization.removeprefix("Bearer ").strip()
try:
client = _get_jwks_client()
signing_key = client.get_signing_key_from_jwt(token)
return jwt.decode(token, signing_key.key, algorithms=["RS256"],
options={"verify_exp": True, "verify_nbf": True})
except ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except InvalidTokenError as exc:
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}")
except RuntimeError:
raise HTTPException(status_code=503, detail="Auth service not configured")
except Exception:
raise HTTPException(status_code=401, detail="Token verification failed")
def require_auth(
authorization: str = Header(default=""),
x_api_key: str = Header(default=""),
) -> dict:
"""
Accept either a Clerk JWT (Authorization: Bearer) or a direct API key (X-Api-Key).
Returns a claims dict. API-key path returns a synthetic service-account dict.
"""
# Fast path: API key (for direct access / testing)
if _api_key and x_api_key == _api_key:
return {"sub": "service-account", "via": "api_key"}
# Clerk JWT path (frontend / production)
if authorization.startswith("Bearer "):
return verify_clerk_token(authorization)
# Dev mode: no keys set — allow all
if not _api_key and not _clerk_jwks_url:
return {"sub": "dev", "via": "open"}
raise HTTPException(status_code=401, detail="Authentication required")
DEVICE_DISPLAY = { DEVICE_DISPLAY = {
"dexcom_g7": "Dexcom G7", "dexcom_g7": "Dexcom G7",
@ -186,7 +232,7 @@ def health_db():
@app.post("/api/upload", response_model=UploadResponse) @app.post("/api/upload", response_model=UploadResponse)
async def upload_csv( async def upload_csv(
file: UploadFile = File(...), file: UploadFile = File(...),
_auth: None = Depends(_require_api_key), claims: dict = Depends(require_auth),
): ):
if not (file.filename or "").endswith(".csv"): if not (file.filename or "").endswith(".csv"):
raise HTTPException(status_code=400, detail="File must be a .csv") raise HTTPException(status_code=400, detail="File must be a .csv")
@ -217,6 +263,7 @@ async def upload_csv(
log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user", log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user",
"success", "0.0.0.0", detail=f"{len(out)} records scored") "success", "0.0.0.0", detail=f"{len(out)} records scored")
clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
batch_id = persist_upload( batch_id = persist_upload(
filename=file.filename or "unknown", filename=file.filename or "unknown",
content_bytes=content, content_bytes=content,
@ -224,6 +271,7 @@ async def upload_csv(
coverage_results=results, coverage_results=results,
skipped_count=len(skipped_reasons), skipped_count=len(skipped_reasons),
mapping_summary=mapping_summary, mapping_summary=mapping_summary,
clerk_org_id=clerk_org_id,
) )
return UploadResponse( return UploadResponse(
@ -245,7 +293,7 @@ class ExportRequest(BaseModel):
@app.post("/api/export") @app.post("/api/export")
async def export_work_queue( async def export_work_queue(
body: ExportRequest, body: ExportRequest,
_auth: None = Depends(_require_api_key), claims: dict = Depends(require_auth),
): ):
"""Generate a downloadable work-queue CSV from a list of scored records.""" """Generate a downloadable work-queue CSV from a list of scored records."""
records = body.records records = body.records

View file

@ -21,21 +21,40 @@ def _sha256(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest() return hashlib.sha256(value.encode()).hexdigest()
def _get_or_create_org() -> str | None: def _get_or_create_org(clerk_org_id: str | None = None) -> str | None:
global _demo_org_id """
if _demo_org_id: Look up the Supabase org UUID.
return _demo_org_id If clerk_org_id is provided (from a verified Clerk JWT), look up by that first.
Falls back to the demo slug for API-key / dev sessions.
"""
client = get_client() client = get_client()
if not client: if not client:
return None return None
try: try:
if clerk_org_id:
# Production path: look up by the Clerk org ID stored on the org record
result = client.table("organizations").select("id").eq("clerk_org_id", clerk_org_id).execute()
if result.data:
return result.data[0]["id"]
# Org exists in Clerk but not yet provisioned in Supabase — create it
result = client.table("organizations").insert({
"name": f"Org {clerk_org_id}",
"slug": clerk_org_id,
"clerk_org_id": clerk_org_id,
}).execute()
org_id = result.data[0]["id"]
logger.info(f"Auto-provisioned org for Clerk org {clerk_org_id}: {org_id}")
return org_id
# Fallback: demo slug (API key auth or dev mode)
global _demo_org_id
if _demo_org_id:
return _demo_org_id
result = client.table("organizations").select("id").eq("slug", DEMO_ORG_SLUG).execute() result = client.table("organizations").select("id").eq("slug", DEMO_ORG_SLUG).execute()
if result.data: if result.data:
_demo_org_id = result.data[0]["id"] _demo_org_id = result.data[0]["id"]
return _demo_org_id return _demo_org_id
result = client.table("organizations").insert({ result = client.table("organizations").insert({
"name": "Gaboro DME — Pilot", "name": "Gaboro DME — Pilot",
"slug": DEMO_ORG_SLUG, "slug": DEMO_ORG_SLUG,
@ -56,6 +75,7 @@ def persist_upload(
coverage_results: list, coverage_results: list,
skipped_count: int, skipped_count: int,
mapping_summary: dict, mapping_summary: dict,
clerk_org_id: str | None = None,
) -> str | None: ) -> str | None:
""" """
Persist one upload batch and all related records to Supabase. Persist one upload batch and all related records to Supabase.
@ -65,7 +85,7 @@ def persist_upload(
if not client: if not client:
return None return None
org_id = _get_or_create_org() org_id = _get_or_create_org(clerk_org_id=clerk_org_id)
if not org_id: if not org_id:
return None return None

View file

@ -3,3 +3,4 @@ uvicorn[standard]>=0.29.0
python-multipart>=0.0.9 python-multipart>=0.0.9
pydantic>=2.0.0 pydantic>=2.0.0
supabase>=2.0.0 supabase>=2.0.0
PyJWT[cryptography]>=2.8.0

View file

@ -10,9 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@clerk/react": "^6.7.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
"pnpm": {
"ignoredBuiltDependencies": ["@clerk/shared"]
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",

View file

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@clerk/react':
specifier: ^6.7.2
version: 6.7.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: react:
specifier: ^19.2.6 specifier: ^19.2.6
version: 19.2.6 version: 19.2.6
@ -118,6 +121,25 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@clerk/react@6.7.2':
resolution: {integrity: sha512-tgRWWTfW+ejXj6WiZqx7iMbtvh45h0445uEvbSNVCyJBEOaBlCF0/ZfAfXlWAQNbRZ+bPIphEfNUmatadZgGOQ==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
'@clerk/shared@4.14.0':
resolution: {integrity: sha512-StZCFJ2rg0ITE3fYjIN9CKuKq8ZACW6l2+5qGCFPPYd4hZphA+VDselmh/lHPsF1ex/WRVUSHvquykAHR8cQbQ==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@ -403,6 +425,9 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/query-core@5.100.14':
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@ -492,6 +517,10 @@ packages:
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -610,6 +639,9 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
globals@17.6.0: globals@17.6.0:
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -646,6 +678,10 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -850,6 +886,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
tailwindcss@4.3.0: tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@ -1047,6 +1086,24 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@clerk/react@6.7.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@clerk/shared': 4.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tslib: 2.8.1
'@clerk/shared@4.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/query-core': 5.100.14
dequal: 2.0.3
glob-to-regexp: 0.4.1
js-cookie: 3.0.7
std-env: 3.10.0
optionalDependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@emnapi/core@1.10.0': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@ -1260,6 +1317,8 @@ snapshots:
tailwindcss: 4.3.0 tailwindcss: 4.3.0
vite: 8.0.14(jiti@2.7.0) vite: 8.0.14(jiti@2.7.0)
'@tanstack/query-core@5.100.14': {}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -1331,6 +1390,8 @@ snapshots:
deep-is@0.1.4: {} deep-is@0.1.4: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
electron-to-chromium@1.5.361: {} electron-to-chromium@1.5.361: {}
@ -1460,6 +1521,8 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob-to-regexp@0.4.1: {}
globals@17.6.0: {} globals@17.6.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
@ -1484,6 +1547,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
jsesc@3.1.0: {} jsesc@3.1.0: {}
@ -1653,6 +1718,8 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
std-env@3.10.0: {}
tailwindcss@4.3.0: {} tailwindcss@4.3.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}
@ -1662,8 +1729,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tslib@2.8.1: tslib@2.8.1: {}
optional: true
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:

View file

@ -0,0 +1,2 @@
allowBuilds:
'@clerk/shared': set this to true or false

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef } from "react";
import { SignIn, UserButton, useAuth } from "@clerk/react";
import { ThemeProvider } from "./ThemeProvider"; import { ThemeProvider } from "./ThemeProvider";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import StatCard from "./components/StatCard"; import StatCard from "./components/StatCard";
@ -12,7 +13,9 @@ import { uploadToBackend, apiRecordToLocal } from "./lib/api";
import { parseCSV, processBatch } from "./lib/coverage"; import { parseCSV, processBatch } from "./lib/coverage";
function AppInner() { function AppInner() {
const { getToken } = useAuth();
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [batchId, setBatchId] = useState(null);
const [activeFilter, setActiveFilter] = useState("all"); const [activeFilter, setActiveFilter] = useState("all");
const [importLabel, setImportLabel] = useState("No data imported"); const [importLabel, setImportLabel] = useState("No data imported");
const csvImportRef = useRef(null); const csvImportRef = useRef(null);
@ -23,17 +26,20 @@ function AppInner() {
const okCount = records.filter((r) => r.flag === "OK").length; const okCount = records.filter((r) => r.flag === "OK").length;
const urgent = ooc + visitDue; const urgent = ooc + visitDue;
const handleResults = useCallback((file) => { const handleResults = useCallback(async (file) => {
const label = new Date().toLocaleDateString("en-US", { const label = new Date().toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}); });
uploadToBackend(file).then((data) => { const token = await getToken().catch(() => null);
const data = await uploadToBackend(file, token);
if (data) { if (data) {
const results = data.records.map(apiRecordToLocal); const results = data.records.map(apiRecordToLocal);
setRecords(results); setRecords(results);
setBatchId(data.batch_id || null);
setImportLabel(`${file.name} · ${label} · via Signal API`); setImportLabel(`${file.name} · ${label} · via Signal API`);
let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`; let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`;
if (data.skipped) msg += ` · ${data.skipped} skipped`; if (data.skipped) msg += ` · ${data.skipped} skipped`;
@ -42,6 +48,7 @@ function AppInner() {
} }
// Backend unreachable process locally // Backend unreachable process locally
setBatchId(null);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const rows = parseCSV(e.target.result); const rows = parseCSV(e.target.result);
@ -53,8 +60,7 @@ function AppInner() {
showToast(msg); showToast(msg);
}; };
reader.readAsText(file); reader.readAsText(file);
}); }, [getToken]);
}, []);
return ( return (
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors"> <div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
@ -88,7 +94,8 @@ function AppInner() {
</div> </div>
<div className="flex items-center gap-[10px]"> <div className="flex items-center gap-[10px]">
<ThemeToggle /> <ThemeToggle />
<CSVExport records={records} /> <CSVExport records={records} batchId={batchId} />
<UserButton />
</div> </div>
</header> </header>
@ -129,6 +136,20 @@ function AppInner() {
} }
export default function App() { export default function App() {
const { isSignedIn, isLoaded } = useAuth();
if (!isLoaded) {
return <div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]" />;
}
if (!isSignedIn) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]">
<SignIn routing="hash" />
</div>
);
}
return ( return (
<ThemeProvider> <ThemeProvider>
<AppInner /> <AppInner />

View file

@ -1,50 +1,48 @@
import { useAuth } from "@clerk/react";
import { exportFromBackend } from "../lib/api";
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage"; import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
export default function CSVExport({ records }) { function downloadBlob(blob, filename) {
const today = new Date().toISOString().slice(0, 10);
const exportCSV = () => {
if (!records || records.length === 0) return;
const headers = [
"Patient ID",
"Device",
"Payer",
"Status",
"Priority Score",
"Days Until Resupply End",
"Recommended Action",
"Resupply End Date",
"Reason",
];
const rows = records.map((r) => [
r.patient_id,
getDeviceDisplay(r.device_type),
r.payer,
getFlagLabel(r.flag),
r.priority,
r.daysUntilEnd,
getFlagAction(r.flag),
r.coverageEndDate || "",
r.reason || "",
]);
const csv = [headers, ...rows]
.map((row) =>
row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
)
.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `signal-work-queue-${today}.csv`; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}
export default function CSVExport({ records, batchId }) {
const { getToken } = useAuth();
const today = new Date().toISOString().slice(0, 10);
const filename = `signal-work-queue-${today}.csv`;
const exportCSV = async () => {
if (!records || records.length === 0) return;
if (batchId) {
const token = await getToken().catch(() => null);
const blob = await exportFromBackend(records, batchId, token);
if (blob) {
downloadBlob(blob, filename);
return;
}
}
// Fallback: client-side CSV (no batchId or backend unavailable)
const headers = [
"Patient ID", "Device", "Payer", "Status", "Priority Score",
"Days Until Resupply End", "Recommended Action", "Resupply End Date", "Reason",
];
const rows = records.map((r) => [
r.patient_id, getDeviceDisplay(r.device_type), r.payer, getFlagLabel(r.flag),
r.priority, r.daysUntilEnd, getFlagAction(r.flag), r.coverageEndDate || "", r.reason || "",
]);
const csv = [headers, ...rows]
.map((row) => row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(","))
.join("\r\n");
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), filename);
}; };
return ( return (

View file

@ -3,7 +3,8 @@
* app can fall back to client-side coverage calculation. * app can fall back to client-side coverage calculation.
*/ */
const BACKEND_URL = "https://signal-api-production-91c2.up.railway.app"; const BACKEND_URL = import.meta.env.VITE_SIGNAL_BACKEND_URL ||
"https://signal-api-production-91c2.up.railway.app";
const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || ""; const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || "";
/** /**
@ -11,13 +12,16 @@ const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || "";
* @param {File} file * @param {File} file
* @returns {Promise<object|null>} API response or null on failure * @returns {Promise<object|null>} API response or null on failure
*/ */
export async function uploadToBackend(file) { export async function uploadToBackend(file, token = null) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const authHeader = token
? { "Authorization": `Bearer ${token}` }
: API_KEY ? { "X-API-Key": API_KEY } : {};
try { try {
const resp = await fetch(`${BACKEND_URL}/api/upload`, { const resp = await fetch(`${BACKEND_URL}/api/upload`, {
method: "POST", method: "POST",
headers: API_KEY ? { "X-API-Key": API_KEY } : {}, headers: authHeader,
body: formData, body: formData,
}); });
if (!resp.ok) { if (!resp.ok) {
@ -35,14 +39,14 @@ export async function uploadToBackend(file) {
* @param {Array} records - scored RecordOut objects from the upload response * @param {Array} records - scored RecordOut objects from the upload response
* @param {string|null} batchId - batch_id from the upload response (for audit trail) * @param {string|null} batchId - batch_id from the upload response (for audit trail)
*/ */
export async function exportFromBackend(records, batchId = null) { export async function exportFromBackend(records, batchId = null, token = null) {
const authHeader = token
? { "Authorization": `Bearer ${token}` }
: API_KEY ? { "X-API-Key": API_KEY } : {};
try { try {
const resp = await fetch(`${BACKEND_URL}/api/export`, { const resp = await fetch(`${BACKEND_URL}/api/export`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json", ...authHeader },
"Content-Type": "application/json",
...(API_KEY ? { "X-API-Key": API_KEY } : {}),
},
body: JSON.stringify({ records, batch_id: batchId }), body: JSON.stringify({ records, batch_id: batchId }),
}); });
if (!resp.ok) return null; if (!resp.ok) return null;

View file

@ -1,10 +1,19 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ClerkProvider } from "@clerk/react";
import App from "./App.jsx"; import App from "./App.jsx";
import "./index.css"; import "./index.css";
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
if (!PUBLISHABLE_KEY) {
throw new Error("Missing VITE_CLERK_PUBLISHABLE_KEY — add it to signal-ui/.env");
}
createRoot(document.getElementById("root")).render( createRoot(document.getElementById("root")).render(
<StrictMode> <StrictMode>
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
<App /> <App />
</ClerkProvider>
</StrictMode> </StrictMode>
); );