@omniradiology/omnirad 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/app/api/ai-config/route.ts +131 -0
- package/app/api/ai-config/test/route.ts +49 -0
- package/app/api/auth/auto-login/route.ts +66 -0
- package/app/api/auth/check/route.ts +17 -0
- package/app/api/auth/login/route.ts +72 -0
- package/app/api/auth/logout/route.ts +25 -0
- package/app/api/auth/me/route.ts +75 -0
- package/app/api/auth/password/route.ts +49 -0
- package/app/api/auth/setup/route.ts +63 -0
- package/app/api/auth/users/route.ts +100 -0
- package/app/api/auth/wipe/route.ts +27 -0
- package/app/api/compliance/anonymize/patient/[id]/route.ts +104 -0
- package/app/api/compliance/audit/route.ts +110 -0
- package/app/api/compliance/export/patient/[id]/route.ts +108 -0
- package/app/api/compliance/restrict/patient/[id]/route.ts +59 -0
- package/app/api/compliance/settings/route.ts +93 -0
- package/app/api/copilot/annotate/route.ts +94 -0
- package/app/api/copilot/chat/route.ts +238 -0
- package/app/api/copilot/history/route.ts +95 -0
- package/app/api/copilot/reports/route.ts +81 -0
- package/app/api/fhir/Bundle/report/[id]/route.ts +85 -0
- package/app/api/fhir/DiagnosticReport/[id]/route.ts +45 -0
- package/app/api/fhir/ImagingStudy/[id]/route.ts +57 -0
- package/app/api/fhir/Patient/[id]/route.ts +26 -0
- package/app/api/fhir/ServiceRequest/route.ts +85 -0
- package/app/api/fhir/config/route.ts +102 -0
- package/app/api/fhir/config/test-connection/route.ts +49 -0
- package/app/api/fhir/metadata/route.ts +51 -0
- package/app/api/pacs/metadata/route.ts +32 -0
- package/app/api/pacs/qido/instances/route.ts +39 -0
- package/app/api/pacs/qido/series/route.ts +38 -0
- package/app/api/pacs/qido/studies/route.ts +37 -0
- package/app/api/pacs/test/route.ts +30 -0
- package/app/api/pacs/wado/render/route.ts +51 -0
- package/app/api/patients/[id]/reports/route.ts +18 -0
- package/app/api/patients/[id]/route.ts +43 -0
- package/app/api/patients/merge/route.ts +57 -0
- package/app/api/patients/route.ts +67 -0
- package/app/api/patients/search/route.ts +25 -0
- package/app/api/reports/[id]/route.ts +84 -0
- package/app/api/reports/[id]/status/route.ts +87 -0
- package/app/api/reports/clear/route.ts +16 -0
- package/app/api/reports/route.ts +112 -0
- package/app/api/segmentation-config/route.ts +238 -0
- package/app/api/settings/route.ts +245 -0
- package/app/api/settings/test-supabase/route.ts +103 -0
- package/app/api/upload/route.ts +48 -0
- package/app/copilot/page.tsx +30 -0
- package/app/globals.css +141 -0
- package/app/history/page.tsx +242 -0
- package/app/icon.svg +3 -0
- package/app/layout.tsx +47 -0
- package/app/login/page.tsx +175 -0
- package/app/pacs/page.tsx +78 -0
- package/app/page.tsx +125 -0
- package/app/patients/[id]/page.tsx +315 -0
- package/app/patients/page.tsx +110 -0
- package/app/profile/page.tsx +208 -0
- package/app/reports/page.tsx +432 -0
- package/app/settings/page.tsx +454 -0
- package/app/setup/page.tsx +199 -0
- package/components/admin/AuditLogTable.tsx +293 -0
- package/components/copilot/ActivityIndicator.tsx +215 -0
- package/components/copilot/ChatHistoryPanel.tsx +140 -0
- package/components/copilot/ChatMessage.tsx +251 -0
- package/components/copilot/ClickableReference.tsx +40 -0
- package/components/copilot/CopilotCornerstoneViewer.tsx +562 -0
- package/components/copilot/CopilotPanel.tsx +311 -0
- package/components/copilot/FindingsList.tsx +75 -0
- package/components/copilot/ViewerPanel.tsx +460 -0
- package/components/copilot/WorkspaceLayout.tsx +398 -0
- package/components/dashboard/AIConfigPanel.tsx +339 -0
- package/components/dashboard/AppearancePanel.tsx +491 -0
- package/components/dashboard/ApprovalModal.tsx +163 -0
- package/components/dashboard/CollaborationPanel.tsx +134 -0
- package/components/dashboard/CopilotConfigPanel.tsx +337 -0
- package/components/dashboard/DicomViewer.tsx +645 -0
- package/components/dashboard/FhirIntegrationPanel.tsx +331 -0
- package/components/dashboard/FullReportOverlay.tsx +269 -0
- package/components/dashboard/ImageViewer.tsx +541 -0
- package/components/dashboard/PatientForm.tsx +597 -0
- package/components/dashboard/RejectionModal.tsx +74 -0
- package/components/dashboard/ReportEditor.tsx +160 -0
- package/components/dashboard/ReportTemplates.tsx +729 -0
- package/components/dashboard/ReportView.tsx +539 -0
- package/components/dashboard/SegmentationConfigPanel.tsx +490 -0
- package/components/dashboard/StudyPlaceholder.tsx +17 -0
- package/components/dashboard/SupabaseIntegrationPanel.tsx +345 -0
- package/components/dashboard/UserManagementPanel.tsx +272 -0
- package/components/layout/ClientLayout.tsx +39 -0
- package/components/layout/Header.tsx +20 -0
- package/components/layout/Sidebar.tsx +119 -0
- package/components/pacs/PacsImageViewerModal.tsx +121 -0
- package/components/pacs/PacsSearchFilters.tsx +117 -0
- package/components/pacs/PacsSeriesViewer.tsx +190 -0
- package/components/pacs/PacsStudyTable.tsx +113 -0
- package/components/patients/patient-card.tsx +117 -0
- package/components/patients/patient-header.tsx +122 -0
- package/components/patients/patient-search.tsx +137 -0
- package/components/patients/patient-timeline.tsx +153 -0
- package/components/settings/ComplianceSettingsPanel.tsx +278 -0
- package/components/settings/SecurityPanel.tsx +418 -0
- package/components/ui/badge.tsx +19 -0
- package/components/ui/basic.tsx +156 -0
- package/db/index.ts +350 -0
- package/db/migrations/0000_odd_quasimodo.sql +117 -0
- package/db/migrations/meta/0000_snapshot.json +778 -0
- package/db/migrations/meta/_journal.json +13 -0
- package/db/schema.ts +239 -0
- package/drizzle.config.ts +10 -0
- package/lib/api.ts +689 -0
- package/lib/auth.ts +22 -0
- package/lib/copilot/action-executor.ts +94 -0
- package/lib/copilot/action-types.ts +72 -0
- package/lib/copilot/coordinate-mapper.ts +84 -0
- package/lib/dicomImageExtractor.ts +103 -0
- package/lib/dicomMetadataParser.ts +111 -0
- package/lib/fhir/client.ts +25 -0
- package/lib/fhir/constants.ts +21 -0
- package/lib/fhir/diagnostic-report.ts +88 -0
- package/lib/fhir/helpers.ts +73 -0
- package/lib/fhir/imaging-study.ts +49 -0
- package/lib/fhir/patient.ts +55 -0
- package/lib/fhir/service-request.ts +85 -0
- package/lib/fhir.ts +6 -0
- package/lib/pacs/dicom-utils.ts +72 -0
- package/lib/pacs/dicomweb.ts +72 -0
- package/lib/pacs/server-utils.ts +37 -0
- package/lib/patients.ts +25 -0
- package/lib/pdfHelper.ts +119 -0
- package/lib/reportHtmlGenerator.ts +581 -0
- package/lib/security/audit.ts +180 -0
- package/lib/security/authz.ts +246 -0
- package/lib/security/phi-redaction.ts +156 -0
- package/lib/security/rate-limit.ts +106 -0
- package/lib/security/secrets.ts +179 -0
- package/lib/supabase.ts +72 -0
- package/lib/utils.ts +6 -0
- package/next.config.ts +35 -0
- package/package.json +76 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.svg +8 -0
- package/public/next.svg +1 -0
- package/public/omnirad-favicon.svg +8 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
- package/types/copilot-viewer.ts +155 -0
- package/types/copilot.ts +105 -0
- package/types/fhir.ts +21 -0
- package/types/html2pdf.d.ts +20 -0
- package/types/index.ts +139 -0
- package/types/pacs.ts +41 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, Input, Label, Button } from "@/components/ui/basic"
|
|
5
|
+
import { Cloud, CheckCircle, XCircle, AlertTriangle, Loader2, Save, Eye, EyeOff, Copy, Check, ChevronDown, ChevronUp, Database, ExternalLink } from "lucide-react"
|
|
6
|
+
import { resetSupabaseClient } from "@/lib/supabase"
|
|
7
|
+
|
|
8
|
+
interface SupabaseIntegrationPanelProps {
|
|
9
|
+
supabaseUrl: string
|
|
10
|
+
supabaseAnonKey: string
|
|
11
|
+
onConfigChange: (field: string, value: string) => void
|
|
12
|
+
onSave: () => Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ConnectionStatus = "idle" | "testing" | "connected" | "error" | "schema-missing"
|
|
16
|
+
|
|
17
|
+
// The consolidated SQL for Supabase setup
|
|
18
|
+
const SETUP_SQL = `-- ═══════════════════════════════════════════════════════════════════
|
|
19
|
+
-- OmniRad — Supabase Cloud Sync Setup
|
|
20
|
+
-- Run this in your Supabase SQL Editor (supabase.com → SQL Editor)
|
|
21
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
-- 1. Reports Table
|
|
24
|
+
CREATE TABLE IF NOT EXISTS public.reports (
|
|
25
|
+
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
26
|
+
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
|
|
27
|
+
patient_id TEXT,
|
|
28
|
+
patient_name TEXT,
|
|
29
|
+
modality TEXT,
|
|
30
|
+
urgency TEXT,
|
|
31
|
+
report_status TEXT DEFAULT 'Pending',
|
|
32
|
+
report_data JSONB NOT NULL,
|
|
33
|
+
pacs_study_uid TEXT,
|
|
34
|
+
pacs_series_uid TEXT,
|
|
35
|
+
pacs_source TEXT
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
ALTER TABLE public.reports ENABLE ROW LEVEL SECURITY;
|
|
39
|
+
CREATE POLICY "reports_all_access" ON public.reports
|
|
40
|
+
FOR ALL USING (true) WITH CHECK (true);
|
|
41
|
+
|
|
42
|
+
-- 2. Patients Table
|
|
43
|
+
CREATE TABLE IF NOT EXISTS public.patients (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
patient_id_number TEXT,
|
|
46
|
+
patient_name TEXT NOT NULL,
|
|
47
|
+
date_of_birth TEXT,
|
|
48
|
+
gender TEXT,
|
|
49
|
+
contact_info TEXT,
|
|
50
|
+
notes TEXT,
|
|
51
|
+
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
|
|
52
|
+
updated_at TIMESTAMPTZ
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
ALTER TABLE public.patients ENABLE ROW LEVEL SECURITY;
|
|
56
|
+
CREATE POLICY "patients_all_access" ON public.patients
|
|
57
|
+
FOR ALL USING (true) WITH CHECK (true);`
|
|
58
|
+
|
|
59
|
+
export function SupabaseIntegrationPanel({
|
|
60
|
+
supabaseUrl,
|
|
61
|
+
supabaseAnonKey,
|
|
62
|
+
onConfigChange,
|
|
63
|
+
onSave,
|
|
64
|
+
}: SupabaseIntegrationPanelProps) {
|
|
65
|
+
const [showKey, setShowKey] = React.useState(false)
|
|
66
|
+
const [connectionStatus, setConnectionStatus] = React.useState<ConnectionStatus>("idle")
|
|
67
|
+
const [connectionMessage, setConnectionMessage] = React.useState("")
|
|
68
|
+
const [reportCount, setReportCount] = React.useState<number | null>(null)
|
|
69
|
+
const [patientsTableExists, setPatientsTableExists] = React.useState(false)
|
|
70
|
+
const [isSaving, setIsSaving] = React.useState(false)
|
|
71
|
+
const [saved, setSaved] = React.useState(false)
|
|
72
|
+
const [showSql, setShowSql] = React.useState(false)
|
|
73
|
+
const [copied, setCopied] = React.useState(false)
|
|
74
|
+
|
|
75
|
+
// Auto-test on mount if credentials exist
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (supabaseUrl?.trim() && supabaseAnonKey?.trim()) {
|
|
78
|
+
testConnection(true)
|
|
79
|
+
}
|
|
80
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
81
|
+
|
|
82
|
+
const testConnection = async (silent = false) => {
|
|
83
|
+
if (!supabaseUrl?.trim() || !supabaseAnonKey?.trim()) {
|
|
84
|
+
setConnectionStatus("error")
|
|
85
|
+
setConnectionMessage("Enter both Project URL and Anon Key first.")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setConnectionStatus("testing")
|
|
90
|
+
setConnectionMessage("Testing connection...")
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch("/api/settings/test-supabase", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/json" },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
supabaseUrl: supabaseUrl.trim(),
|
|
98
|
+
supabaseAnonKey: supabaseAnonKey.trim(),
|
|
99
|
+
}),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const data = await res.json()
|
|
103
|
+
|
|
104
|
+
if (data.success && data.connected) {
|
|
105
|
+
if (!data.reportsTableExists) {
|
|
106
|
+
setConnectionStatus("schema-missing")
|
|
107
|
+
setConnectionMessage(data.message || "Connected, but tables are missing. Run the SQL setup.")
|
|
108
|
+
setReportCount(null)
|
|
109
|
+
setPatientsTableExists(false)
|
|
110
|
+
} else {
|
|
111
|
+
setConnectionStatus("connected")
|
|
112
|
+
setConnectionMessage(data.message || "Connected!")
|
|
113
|
+
setReportCount(data.reportCount ?? 0)
|
|
114
|
+
setPatientsTableExists(data.patientsTableExists ?? false)
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
setConnectionStatus("error")
|
|
118
|
+
setConnectionMessage(data.error || "Connection failed.")
|
|
119
|
+
setReportCount(null)
|
|
120
|
+
setPatientsTableExists(false)
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
setConnectionStatus("error")
|
|
124
|
+
setConnectionMessage(`Network error: ${err instanceof Error ? err.message : "Unknown"}`)
|
|
125
|
+
setReportCount(null)
|
|
126
|
+
setPatientsTableExists(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleSave = async () => {
|
|
131
|
+
setIsSaving(true)
|
|
132
|
+
try {
|
|
133
|
+
await onSave()
|
|
134
|
+
// Reset the cached Supabase client so new credentials take effect immediately
|
|
135
|
+
resetSupabaseClient()
|
|
136
|
+
setSaved(true)
|
|
137
|
+
setTimeout(() => setSaved(false), 2500)
|
|
138
|
+
// Re-test after save
|
|
139
|
+
if (supabaseUrl?.trim() && supabaseAnonKey?.trim()) {
|
|
140
|
+
setTimeout(() => testConnection(true), 500)
|
|
141
|
+
} else {
|
|
142
|
+
setConnectionStatus("idle")
|
|
143
|
+
setConnectionMessage("")
|
|
144
|
+
setReportCount(null)
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
setIsSaving(false)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const handleCopySql = async () => {
|
|
152
|
+
try {
|
|
153
|
+
await navigator.clipboard.writeText(SETUP_SQL)
|
|
154
|
+
setCopied(true)
|
|
155
|
+
setTimeout(() => setCopied(false), 2000)
|
|
156
|
+
} catch {
|
|
157
|
+
// Fallback for non-HTTPS contexts
|
|
158
|
+
const ta = document.createElement("textarea")
|
|
159
|
+
ta.value = SETUP_SQL
|
|
160
|
+
document.body.appendChild(ta)
|
|
161
|
+
ta.select()
|
|
162
|
+
document.execCommand("copy")
|
|
163
|
+
document.body.removeChild(ta)
|
|
164
|
+
setCopied(true)
|
|
165
|
+
setTimeout(() => setCopied(false), 2000)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const statusIcon = () => {
|
|
170
|
+
switch (connectionStatus) {
|
|
171
|
+
case "testing":
|
|
172
|
+
return <Loader2 size={18} className="animate-spin text-blue-400" />
|
|
173
|
+
case "connected":
|
|
174
|
+
return <CheckCircle size={18} className="text-green-500" />
|
|
175
|
+
case "error":
|
|
176
|
+
return <XCircle size={18} className="text-red-500" />
|
|
177
|
+
case "schema-missing":
|
|
178
|
+
return <AlertTriangle size={18} className="text-yellow-500" />
|
|
179
|
+
default:
|
|
180
|
+
return <Cloud size={18} className="text-text-muted" />
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const statusColor = () => {
|
|
185
|
+
switch (connectionStatus) {
|
|
186
|
+
case "connected": return "border-green-500/30 bg-green-950/10"
|
|
187
|
+
case "error": return "border-red-500/30 bg-red-950/10"
|
|
188
|
+
case "schema-missing": return "border-yellow-500/30 bg-yellow-950/10"
|
|
189
|
+
case "testing": return "border-blue-500/30 bg-blue-950/10"
|
|
190
|
+
default: return "border-border-primary bg-bg-panel/50"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const isConfigured = supabaseUrl?.trim() && supabaseAnonKey?.trim()
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
198
|
+
<CardHeader>
|
|
199
|
+
<CardTitle className="text-text-heading flex items-center gap-2">
|
|
200
|
+
<Cloud size={20} className="text-emerald-500" />
|
|
201
|
+
Supabase Cloud Sync
|
|
202
|
+
</CardTitle>
|
|
203
|
+
<p className="text-sm text-text-secondary">
|
|
204
|
+
Sync reports to the cloud for cross-device access. Images are auto-stripped before upload to save bandwidth.
|
|
205
|
+
</p>
|
|
206
|
+
</CardHeader>
|
|
207
|
+
<CardContent className="space-y-5">
|
|
208
|
+
{/* Connection Status Banner */}
|
|
209
|
+
{connectionStatus !== "idle" && (
|
|
210
|
+
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-all duration-300 ${statusColor()}`}>
|
|
211
|
+
{statusIcon()}
|
|
212
|
+
<div className="flex-1 min-w-0">
|
|
213
|
+
<p className="text-sm font-medium text-text-primary truncate">{connectionMessage}</p>
|
|
214
|
+
{connectionStatus === "connected" && reportCount !== null && (
|
|
215
|
+
<div className="flex items-center gap-3 mt-1">
|
|
216
|
+
<span className="text-xs text-text-muted flex items-center gap-1">
|
|
217
|
+
<Database size={12} />
|
|
218
|
+
{reportCount} report{reportCount !== 1 ? "s" : ""} synced
|
|
219
|
+
</span>
|
|
220
|
+
{!patientsTableExists && (
|
|
221
|
+
<span className="text-xs text-yellow-500">• Patients table missing</span>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* Project URL */}
|
|
230
|
+
<div>
|
|
231
|
+
<Label htmlFor="supabaseUrl" className="text-text-primary">
|
|
232
|
+
Supabase Project URL *
|
|
233
|
+
</Label>
|
|
234
|
+
<Input
|
|
235
|
+
id="supabaseUrl"
|
|
236
|
+
type="url"
|
|
237
|
+
placeholder="https://your-project.supabase.co"
|
|
238
|
+
value={supabaseUrl}
|
|
239
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onConfigChange("supabaseUrl", e.target.value)}
|
|
240
|
+
className="mt-1 bg-bg-panel border-border-primary text-text-primary placeholder-text-muted"
|
|
241
|
+
/>
|
|
242
|
+
<p className="text-xs text-text-muted mt-1">
|
|
243
|
+
Find this at <span className="text-text-secondary">Supabase Dashboard → Settings → API → Project URL</span>
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Anon Key */}
|
|
248
|
+
<div>
|
|
249
|
+
<Label htmlFor="supabaseAnonKey" className="text-text-primary">
|
|
250
|
+
Anon Public Key *
|
|
251
|
+
</Label>
|
|
252
|
+
<div className="relative mt-1">
|
|
253
|
+
<Input
|
|
254
|
+
id="supabaseAnonKey"
|
|
255
|
+
type={showKey ? "text" : "password"}
|
|
256
|
+
placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
|
257
|
+
value={supabaseAnonKey}
|
|
258
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onConfigChange("supabaseAnonKey", e.target.value)}
|
|
259
|
+
className="bg-bg-panel border-border-primary text-text-primary placeholder-text-muted pr-10"
|
|
260
|
+
/>
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={() => setShowKey(!showKey)}
|
|
264
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
|
|
265
|
+
title={showKey ? "Hide key" : "Show key"}
|
|
266
|
+
>
|
|
267
|
+
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
<p className="text-xs text-text-muted mt-1">
|
|
271
|
+
Find this at <span className="text-text-secondary">Supabase Dashboard → Settings → API → anon public key</span>
|
|
272
|
+
</p>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Action Buttons */}
|
|
276
|
+
<div className="flex gap-3">
|
|
277
|
+
<Button
|
|
278
|
+
onClick={() => testConnection()}
|
|
279
|
+
disabled={connectionStatus === "testing" || !isConfigured}
|
|
280
|
+
variant="outline"
|
|
281
|
+
className="flex-1 gap-2 border-border-primary text-text-primary hover:bg-white/5"
|
|
282
|
+
>
|
|
283
|
+
{connectionStatus === "testing" ? (
|
|
284
|
+
<Loader2 size={16} className="animate-spin" />
|
|
285
|
+
) : (
|
|
286
|
+
<Cloud size={16} />
|
|
287
|
+
)}
|
|
288
|
+
{connectionStatus === "testing" ? "Testing..." : "Test Connection"}
|
|
289
|
+
</Button>
|
|
290
|
+
<Button
|
|
291
|
+
onClick={handleSave}
|
|
292
|
+
disabled={isSaving}
|
|
293
|
+
className="flex-1 bg-primary-main hover:bg-primary-hover text-white gap-2"
|
|
294
|
+
>
|
|
295
|
+
{saved ? <Check size={16} /> : <Save size={16} />}
|
|
296
|
+
{isSaving ? "Saving..." : saved ? "Saved!" : "Save"}
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* SQL Setup Section */}
|
|
301
|
+
<div className="border-t border-border-primary pt-4">
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => setShowSql(!showSql)}
|
|
304
|
+
className="flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-text-primary transition-colors w-full text-left"
|
|
305
|
+
>
|
|
306
|
+
<Database size={16} className="text-emerald-500" />
|
|
307
|
+
<span className="flex-1">Database Setup SQL</span>
|
|
308
|
+
{showSql ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
309
|
+
</button>
|
|
310
|
+
<p className="text-xs text-text-muted mt-1">
|
|
311
|
+
Run this SQL once in your Supabase SQL Editor to create the required tables.
|
|
312
|
+
</p>
|
|
313
|
+
|
|
314
|
+
{showSql && (
|
|
315
|
+
<div className="mt-3 space-y-2">
|
|
316
|
+
<div className="flex justify-between items-center">
|
|
317
|
+
<a
|
|
318
|
+
href="https://supabase.com/dashboard"
|
|
319
|
+
target="_blank"
|
|
320
|
+
rel="noopener noreferrer"
|
|
321
|
+
className="text-xs text-emerald-500 hover:text-emerald-400 flex items-center gap-1 transition-colors"
|
|
322
|
+
>
|
|
323
|
+
Open Supabase Dashboard
|
|
324
|
+
<ExternalLink size={12} />
|
|
325
|
+
</a>
|
|
326
|
+
<Button
|
|
327
|
+
size="sm"
|
|
328
|
+
variant="outline"
|
|
329
|
+
onClick={handleCopySql}
|
|
330
|
+
className="h-7 px-2.5 text-xs gap-1.5 border-border-primary text-text-secondary hover:text-text-primary hover:bg-white/5"
|
|
331
|
+
>
|
|
332
|
+
{copied ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
|
|
333
|
+
{copied ? "Copied!" : "Copy SQL"}
|
|
334
|
+
</Button>
|
|
335
|
+
</div>
|
|
336
|
+
<pre className="text-xs bg-bg-primary border border-border-primary rounded-lg p-4 overflow-x-auto max-h-[300px] overflow-y-auto text-text-secondary font-mono leading-relaxed">
|
|
337
|
+
{SETUP_SQL}
|
|
338
|
+
</pre>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</CardContent>
|
|
343
|
+
</Card>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Card, CardHeader, CardTitle, CardContent, Button, Input, Label, Badge } from "@/components/ui/basic";
|
|
5
|
+
import { Trash2, UserPlus } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
interface DbUser {
|
|
8
|
+
id: string;
|
|
9
|
+
fullName: string;
|
|
10
|
+
username: string;
|
|
11
|
+
email: string;
|
|
12
|
+
role: "Admin" | "User";
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function UserManagementPanel() {
|
|
17
|
+
const [users, setUsers] = React.useState<DbUser[]>([]);
|
|
18
|
+
const [isLoading, setIsLoading] = React.useState(true);
|
|
19
|
+
const [isAdmin, setIsAdmin] = React.useState(false);
|
|
20
|
+
|
|
21
|
+
// Form state
|
|
22
|
+
const [newUser, setNewUser] = React.useState({
|
|
23
|
+
fullName: "",
|
|
24
|
+
username: "",
|
|
25
|
+
email: "",
|
|
26
|
+
password: "",
|
|
27
|
+
role: "User" as const
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const [error, setError] = React.useState("");
|
|
31
|
+
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
// First check if current user is admin visually (API will also block them)
|
|
34
|
+
fetch('/api/auth/me')
|
|
35
|
+
.then(res => res.json())
|
|
36
|
+
.then(me => {
|
|
37
|
+
if (me.role === 'Admin') {
|
|
38
|
+
setIsAdmin(true);
|
|
39
|
+
loadUsers();
|
|
40
|
+
} else {
|
|
41
|
+
setIsAdmin(false);
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.catch(() => setIsLoading(false));
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const loadUsers = () => {
|
|
49
|
+
fetch('/api/auth/users')
|
|
50
|
+
.then(res => res.json())
|
|
51
|
+
.then(data => {
|
|
52
|
+
if (Array.isArray(data)) setUsers(data);
|
|
53
|
+
setIsLoading(false);
|
|
54
|
+
})
|
|
55
|
+
.catch(e => {
|
|
56
|
+
console.error("Error loading users:", e);
|
|
57
|
+
setIsLoading(false);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleAddUser = async () => {
|
|
62
|
+
setError("");
|
|
63
|
+
if (!newUser.fullName || !newUser.username || !newUser.password || !newUser.email) {
|
|
64
|
+
setError("All fields are required.");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch('/api/auth/users', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify(newUser),
|
|
73
|
+
});
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
|
|
76
|
+
if (!res.ok) throw new Error(data.error);
|
|
77
|
+
|
|
78
|
+
setNewUser({ fullName: "", username: "", email: "", password: "", role: "User" });
|
|
79
|
+
loadUsers();
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
setError(err.message);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleRemoveUser = async (id: string, role: string) => {
|
|
86
|
+
if (role === 'Admin') {
|
|
87
|
+
const adminCount = users.filter(u => u.role === 'Admin').length;
|
|
88
|
+
if (adminCount <= 1) {
|
|
89
|
+
alert("Cannot delete the last Admin account.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (confirm("Are you sure you want to completely remove this user account?")) {
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`/api/auth/users?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
loadUsers();
|
|
99
|
+
} else {
|
|
100
|
+
const data = await res.json();
|
|
101
|
+
alert(data.error || "Failed to remove user");
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const toggleRole = async (user: DbUser) => {
|
|
110
|
+
const newRole = user.role === 'Admin' ? 'User' : 'Admin';
|
|
111
|
+
|
|
112
|
+
if (user.role === 'Admin') {
|
|
113
|
+
const adminCount = users.filter(u => u.role === 'Admin').length;
|
|
114
|
+
if (adminCount <= 1) {
|
|
115
|
+
alert("Cannot demote the last Admin account.");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch('/api/auth/users', {
|
|
122
|
+
method: 'PUT',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ id: user.id, role: newRole }),
|
|
125
|
+
});
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
loadUsers();
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(e);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (isLoading) return null;
|
|
135
|
+
if (!isAdmin) return null; // Hide panel entirely if not admin
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
139
|
+
<CardHeader>
|
|
140
|
+
<CardTitle>User Management</CardTitle>
|
|
141
|
+
</CardHeader>
|
|
142
|
+
<CardContent className="space-y-6">
|
|
143
|
+
|
|
144
|
+
{/* Add User Form */}
|
|
145
|
+
<div className="bg-bg-panel/30 p-6 rounded-xl border border-border-primary space-y-6 shadow-sm">
|
|
146
|
+
<div className="flex items-center justify-between border-b border-border-primary/50 pb-4">
|
|
147
|
+
<div>
|
|
148
|
+
<h3 className="text-lg font-semibold text-text-heading">Create New User</h3>
|
|
149
|
+
<p className="text-sm text-text-muted mt-1">Add a new team member to OmniRad and assign their permissions.</p>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="p-3 bg-primary/10 rounded-full hidden sm:block">
|
|
152
|
+
<UserPlus size={24} className="text-primary" />
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
|
157
|
+
<div className="space-y-2">
|
|
158
|
+
<Label className="text-text-secondary text-xs uppercase tracking-wider font-semibold">Full Name</Label>
|
|
159
|
+
<Input
|
|
160
|
+
value={newUser.fullName}
|
|
161
|
+
onChange={(e) => setNewUser({ ...newUser, fullName: e.target.value })}
|
|
162
|
+
placeholder="Jane Doe"
|
|
163
|
+
className="h-10 bg-bg-surface"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="space-y-2">
|
|
167
|
+
<Label className="text-text-secondary text-xs uppercase tracking-wider font-semibold">Username</Label>
|
|
168
|
+
<Input
|
|
169
|
+
value={newUser.username}
|
|
170
|
+
onChange={(e) => setNewUser({ ...newUser, username: e.target.value.toLowerCase().replace(/\s/g, '') })}
|
|
171
|
+
placeholder="janed"
|
|
172
|
+
className="h-10 bg-bg-surface font-mono text-sm"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="space-y-2">
|
|
176
|
+
<Label className="text-text-secondary text-xs uppercase tracking-wider font-semibold">Email Address</Label>
|
|
177
|
+
<Input
|
|
178
|
+
type="email"
|
|
179
|
+
value={newUser.email}
|
|
180
|
+
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
|
181
|
+
placeholder="jane@example.com"
|
|
182
|
+
className="h-10 bg-bg-surface"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
<Label className="text-text-secondary text-xs uppercase tracking-wider font-semibold">Temporary Password</Label>
|
|
187
|
+
<Input
|
|
188
|
+
type="text"
|
|
189
|
+
value={newUser.password}
|
|
190
|
+
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
|
191
|
+
placeholder="Secure temporary password"
|
|
192
|
+
className="h-10 bg-bg-surface"
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="space-y-2 md:col-span-2">
|
|
196
|
+
<Label className="text-text-secondary text-xs uppercase tracking-wider font-semibold">Role Access</Label>
|
|
197
|
+
<select
|
|
198
|
+
className="flex h-10 w-full md:w-1/2 rounded-md border border-border-primary bg-bg-surface px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary shadow-sm"
|
|
199
|
+
value={newUser.role}
|
|
200
|
+
onChange={(e) => setNewUser({ ...newUser, role: e.target.value as any })}
|
|
201
|
+
>
|
|
202
|
+
<option value="User">User</option>
|
|
203
|
+
<option value="Admin">Admin</option>
|
|
204
|
+
</select>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div className="flex flex-col sm:flex-row items-center justify-between pt-4 border-t border-border-primary/50 gap-4 mt-2">
|
|
209
|
+
<div className="text-red-500 text-sm font-medium">{error}</div>
|
|
210
|
+
<Button onClick={handleAddUser} className="bg-primary hover:bg-primary-hover text-white h-10 px-8 w-full sm:w-auto shadow-md">
|
|
211
|
+
<UserPlus size={16} className="mr-2" /> Create User
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Users List */}
|
|
217
|
+
<div className="rounded-md border border-border-primary overflow-hidden">
|
|
218
|
+
<table className="w-full text-sm text-left">
|
|
219
|
+
<thead className="bg-bg-panel text-text-secondary border-b border-border-primary">
|
|
220
|
+
<tr>
|
|
221
|
+
<th className="px-4 py-3 font-medium">Name</th>
|
|
222
|
+
<th className="px-4 py-3 font-medium">Username</th>
|
|
223
|
+
<th className="px-4 py-3 font-medium">Role</th>
|
|
224
|
+
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
|
225
|
+
</tr>
|
|
226
|
+
</thead>
|
|
227
|
+
<tbody className="divide-y divide-border-primary">
|
|
228
|
+
{users.length === 0 && (
|
|
229
|
+
<tr>
|
|
230
|
+
<td colSpan={4} className="px-4 py-8 text-center text-text-muted">No users found.</td>
|
|
231
|
+
</tr>
|
|
232
|
+
)}
|
|
233
|
+
{users.map((user) => (
|
|
234
|
+
<tr key={user.id} className="hover:bg-bg-panel/30 transition-colors">
|
|
235
|
+
<td className="px-4 py-3">
|
|
236
|
+
<div className="font-medium text-text-heading">{user.fullName}</div>
|
|
237
|
+
<div className="text-xs text-text-muted">{user.email}</div>
|
|
238
|
+
</td>
|
|
239
|
+
<td className="px-4 py-3">
|
|
240
|
+
<div className="text-text-primary font-mono text-xs">@{user.username}</div>
|
|
241
|
+
</td>
|
|
242
|
+
<td className="px-4 py-3">
|
|
243
|
+
<button
|
|
244
|
+
onClick={() => toggleRole(user)}
|
|
245
|
+
className="focus:outline-none hover:opacity-80 transition-opacity"
|
|
246
|
+
title="Click to toggle role"
|
|
247
|
+
>
|
|
248
|
+
<Badge variant="outline" className={`text-text-primary border-border-primary ${user.role === 'Admin' ? 'bg-primary/20 text-primary-hover border-primary/30' : ''}`}>
|
|
249
|
+
{user.role}
|
|
250
|
+
</Badge>
|
|
251
|
+
</button>
|
|
252
|
+
</td>
|
|
253
|
+
<td className="px-4 py-3 text-right">
|
|
254
|
+
<Button
|
|
255
|
+
variant="ghost"
|
|
256
|
+
size="sm"
|
|
257
|
+
className="text-red-500 hover:text-red-400 hover:bg-red-950/20"
|
|
258
|
+
onClick={() => handleRemoveUser(user.id, user.role)}
|
|
259
|
+
title="Revoke Access / Delete"
|
|
260
|
+
>
|
|
261
|
+
<Trash2 size={16} />
|
|
262
|
+
</Button>
|
|
263
|
+
</td>
|
|
264
|
+
</tr>
|
|
265
|
+
))}
|
|
266
|
+
</tbody>
|
|
267
|
+
</table>
|
|
268
|
+
</div>
|
|
269
|
+
</CardContent>
|
|
270
|
+
</Card>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { Sidebar } from "@/components/layout/Sidebar";
|
|
5
|
+
import { Header } from "@/components/layout/Header";
|
|
6
|
+
|
|
7
|
+
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const pathname = usePathname();
|
|
9
|
+
const isAuthPage = pathname?.startsWith('/login') || pathname?.startsWith('/setup');
|
|
10
|
+
const isSettingsPage = pathname?.startsWith('/settings');
|
|
11
|
+
|
|
12
|
+
if (isAuthPage) {
|
|
13
|
+
return (
|
|
14
|
+
<main className="flex-1 w-full h-full overflow-auto bg-bg-secondary">
|
|
15
|
+
{children}
|
|
16
|
+
</main>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (isSettingsPage) {
|
|
21
|
+
return (
|
|
22
|
+
<main className="flex-1 w-full h-full overflow-hidden bg-bg-primary">
|
|
23
|
+
{children}
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<Header />
|
|
31
|
+
<div className="hidden md:block z-50">
|
|
32
|
+
<Sidebar />
|
|
33
|
+
</div>
|
|
34
|
+
<main className="flex-1 md:ml-20 h-full overflow-auto relative w-full">
|
|
35
|
+
{children}
|
|
36
|
+
</main>
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Menu, Activity } from 'lucide-react';
|
|
4
|
+
import { Button } from '@/components/ui/basic';
|
|
5
|
+
|
|
6
|
+
export function Header({ onMenuClick }: { onMenuClick?: () => void }) {
|
|
7
|
+
return (
|
|
8
|
+
<header className="h-16 border-b border-border-primary bg-bg-surface flex items-center px-4 justify-between md:hidden">
|
|
9
|
+
<div className="flex items-center gap-3">
|
|
10
|
+
<Button variant="ghost" size="icon" onClick={onMenuClick} className="text-text-primary">
|
|
11
|
+
<Menu className="h-6 w-6" />
|
|
12
|
+
</Button>
|
|
13
|
+
<div className="flex items-center gap-2 text-primary">
|
|
14
|
+
<Activity className="h-6 w-6" />
|
|
15
|
+
<span className="text-lg font-bold text-text-heading tracking-tight">OmniRad</span>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</header>
|
|
19
|
+
);
|
|
20
|
+
}
|