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:
parent
35a61e11d5
commit
ec2cd24bd7
11 changed files with 445 additions and 85 deletions
187
docs/phase3-setup-checklist.md
Normal file
187
docs/phase3-setup-checklist.md
Normal 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
|
||||
```
|
||||
|
|
@ -5,9 +5,12 @@ import io
|
|||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError
|
||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -49,11 +52,54 @@ app.add_middleware(
|
|||
# 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.
|
||||
_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:
|
||||
if _api_key and x_api_key != _api_key:
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_jwks_client() -> PyJWKClient:
|
||||
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 = {
|
||||
"dexcom_g7": "Dexcom G7",
|
||||
|
|
@ -186,7 +232,7 @@ def health_db():
|
|||
@app.post("/api/upload", response_model=UploadResponse)
|
||||
async def upload_csv(
|
||||
file: UploadFile = File(...),
|
||||
_auth: None = Depends(_require_api_key),
|
||||
claims: dict = Depends(require_auth),
|
||||
):
|
||||
if not (file.filename or "").endswith(".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",
|
||||
"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(
|
||||
filename=file.filename or "unknown",
|
||||
content_bytes=content,
|
||||
|
|
@ -224,6 +271,7 @@ async def upload_csv(
|
|||
coverage_results=results,
|
||||
skipped_count=len(skipped_reasons),
|
||||
mapping_summary=mapping_summary,
|
||||
clerk_org_id=clerk_org_id,
|
||||
)
|
||||
|
||||
return UploadResponse(
|
||||
|
|
@ -245,7 +293,7 @@ class ExportRequest(BaseModel):
|
|||
@app.post("/api/export")
|
||||
async def export_work_queue(
|
||||
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."""
|
||||
records = body.records
|
||||
|
|
|
|||
|
|
@ -21,21 +21,40 @@ def _sha256(value: str) -> str:
|
|||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_or_create_org() -> str | None:
|
||||
global _demo_org_id
|
||||
if _demo_org_id:
|
||||
return _demo_org_id
|
||||
|
||||
def _get_or_create_org(clerk_org_id: str | None = None) -> str | None:
|
||||
"""
|
||||
Look up the Supabase org UUID.
|
||||
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()
|
||||
if not client:
|
||||
return None
|
||||
|
||||
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()
|
||||
if result.data:
|
||||
_demo_org_id = result.data[0]["id"]
|
||||
return _demo_org_id
|
||||
|
||||
result = client.table("organizations").insert({
|
||||
"name": "Gaboro DME — Pilot",
|
||||
"slug": DEMO_ORG_SLUG,
|
||||
|
|
@ -56,6 +75,7 @@ def persist_upload(
|
|||
coverage_results: list,
|
||||
skipped_count: int,
|
||||
mapping_summary: dict,
|
||||
clerk_org_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Persist one upload batch and all related records to Supabase.
|
||||
|
|
@ -65,7 +85,7 @@ def persist_upload(
|
|||
if not client:
|
||||
return None
|
||||
|
||||
org_id = _get_or_create_org()
|
||||
org_id = _get_or_create_org(clerk_org_id=clerk_org_id)
|
||||
if not org_id:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ uvicorn[standard]>=0.29.0
|
|||
python-multipart>=0.0.9
|
||||
pydantic>=2.0.0
|
||||
supabase>=2.0.0
|
||||
PyJWT[cryptography]>=2.8.0
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/react": "^6.7.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"ignoredBuiltDependencies": ["@clerk/shared"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
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:
|
||||
specifier: ^19.2.6
|
||||
version: 19.2.6
|
||||
|
|
@ -118,6 +121,25 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
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':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
|
|
@ -403,6 +425,9 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tanstack/query-core@5.100.14':
|
||||
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
|
|
@ -492,6 +517,10 @@ packages:
|
|||
deep-is@0.1.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -610,6 +639,9 @@ packages:
|
|||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
glob-to-regexp@0.4.1:
|
||||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||
|
||||
globals@17.6.0:
|
||||
resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -646,6 +678,10 @@ packages:
|
|||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
|
@ -850,6 +886,9 @@ packages:
|
|||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
tailwindcss@4.3.0:
|
||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||
|
||||
|
|
@ -1047,6 +1086,24 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
|
|
@ -1260,6 +1317,8 @@ snapshots:
|
|||
tailwindcss: 4.3.0
|
||||
vite: 8.0.14(jiti@2.7.0)
|
||||
|
||||
'@tanstack/query-core@5.100.14': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -1331,6 +1390,8 @@ snapshots:
|
|||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
electron-to-chromium@1.5.361: {}
|
||||
|
|
@ -1460,6 +1521,8 @@ snapshots:
|
|||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob-to-regexp@0.4.1: {}
|
||||
|
||||
globals@17.6.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
|
@ -1484,6 +1547,8 @@ snapshots:
|
|||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
|
@ -1653,6 +1718,8 @@ snapshots:
|
|||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
tailwindcss@4.3.0: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
|
@ -1662,8 +1729,7 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
|
|
|
|||
2
signal-ui/pnpm-workspace.yaml
Normal file
2
signal-ui/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
'@clerk/shared': set this to true or false
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
import { SignIn, UserButton, useAuth } from "@clerk/react";
|
||||
import { ThemeProvider } from "./ThemeProvider";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import StatCard from "./components/StatCard";
|
||||
|
|
@ -12,7 +13,9 @@ import { uploadToBackend, apiRecordToLocal } from "./lib/api";
|
|||
import { parseCSV, processBatch } from "./lib/coverage";
|
||||
|
||||
function AppInner() {
|
||||
const { getToken } = useAuth();
|
||||
const [records, setRecords] = useState([]);
|
||||
const [batchId, setBatchId] = useState(null);
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const [importLabel, setImportLabel] = useState("No data imported");
|
||||
const csvImportRef = useRef(null);
|
||||
|
|
@ -23,17 +26,20 @@ function AppInner() {
|
|||
const okCount = records.filter((r) => r.flag === "OK").length;
|
||||
const urgent = ooc + visitDue;
|
||||
|
||||
const handleResults = useCallback((file) => {
|
||||
const handleResults = useCallback(async (file) => {
|
||||
const label = new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
uploadToBackend(file).then((data) => {
|
||||
const token = await getToken().catch(() => null);
|
||||
const data = await uploadToBackend(file, token);
|
||||
|
||||
if (data) {
|
||||
const results = data.records.map(apiRecordToLocal);
|
||||
setRecords(results);
|
||||
setBatchId(data.batch_id || null);
|
||||
setImportLabel(`${file.name} · ${label} · via Signal API`);
|
||||
let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`;
|
||||
if (data.skipped) msg += ` · ${data.skipped} skipped`;
|
||||
|
|
@ -42,6 +48,7 @@ function AppInner() {
|
|||
}
|
||||
|
||||
// Backend unreachable — process locally
|
||||
setBatchId(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const rows = parseCSV(e.target.result);
|
||||
|
|
@ -53,8 +60,7 @@ function AppInner() {
|
|||
showToast(msg);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}, []);
|
||||
}, [getToken]);
|
||||
|
||||
return (
|
||||
<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 className="flex items-center gap-[10px]">
|
||||
<ThemeToggle />
|
||||
<CSVExport records={records} />
|
||||
<CSVExport records={records} batchId={batchId} />
|
||||
<UserButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -129,6 +136,20 @@ function AppInner() {
|
|||
}
|
||||
|
||||
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 (
|
||||
<ThemeProvider>
|
||||
<AppInner />
|
||||
|
|
|
|||
|
|
@ -1,50 +1,48 @@
|
|||
import { useAuth } from "@clerk/react";
|
||||
import { exportFromBackend } from "../lib/api";
|
||||
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
|
||||
|
||||
export default function CSVExport({ records }) {
|
||||
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;" });
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `signal-work-queue-${today}.csv`;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* 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 || "";
|
||||
|
||||
/**
|
||||
|
|
@ -11,13 +12,16 @@ const API_KEY = import.meta.env.VITE_SIGNAL_API_KEY || "";
|
|||
* @param {File} file
|
||||
* @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();
|
||||
formData.append("file", file);
|
||||
const authHeader = token
|
||||
? { "Authorization": `Bearer ${token}` }
|
||||
: API_KEY ? { "X-API-Key": API_KEY } : {};
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
headers: API_KEY ? { "X-API-Key": API_KEY } : {},
|
||||
headers: authHeader,
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
|
@ -35,14 +39,14 @@ export async function uploadToBackend(file) {
|
|||
* @param {Array} records - scored RecordOut objects from the upload response
|
||||
* @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 {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(API_KEY ? { "X-API-Key": API_KEY } : {}),
|
||||
},
|
||||
headers: { "Content-Type": "application/json", ...authHeader },
|
||||
body: JSON.stringify({ records, batch_id: batchId }),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ClerkProvider } from "@clerk/react";
|
||||
import App from "./App.jsx";
|
||||
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(
|
||||
<StrictMode>
|
||||
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
|
||||
<App />
|
||||
</ClerkProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
Loading…
Reference in a new issue