@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,418 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle, Button } from "@/components/ui/basic"
|
|
5
|
+
import { Shield, ShieldCheck, ShieldOff, Lock, Unlock, AlertTriangle, Clock, User } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
interface SecuritySettings {
|
|
8
|
+
appLockEnabled: boolean
|
|
9
|
+
defaultUserId: string | null
|
|
10
|
+
updatedBy: string | null
|
|
11
|
+
updatedAt: string | null
|
|
12
|
+
updatedByName: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CurrentUser {
|
|
16
|
+
id: string
|
|
17
|
+
fullName: string
|
|
18
|
+
username?: string
|
|
19
|
+
role: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SecurityPanel() {
|
|
23
|
+
const [settings, setSettings] = React.useState<SecuritySettings>({
|
|
24
|
+
appLockEnabled: true,
|
|
25
|
+
defaultUserId: null,
|
|
26
|
+
updatedBy: null,
|
|
27
|
+
updatedAt: null,
|
|
28
|
+
updatedByName: null,
|
|
29
|
+
})
|
|
30
|
+
const [usersList, setUsersList] = React.useState<CurrentUser[]>([])
|
|
31
|
+
const [currentUser, setCurrentUser] = React.useState<CurrentUser | null>(null)
|
|
32
|
+
const [isLoading, setIsLoading] = React.useState(true)
|
|
33
|
+
const [isSaving, setIsSaving] = React.useState(false)
|
|
34
|
+
const [showConfirmModal, setShowConfirmModal] = React.useState(false)
|
|
35
|
+
const [pendingLockState, setPendingLockState] = React.useState<boolean>(true)
|
|
36
|
+
const [saveSuccess, setSaveSuccess] = React.useState(false)
|
|
37
|
+
|
|
38
|
+
// Load current user and security settings
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
Promise.all([
|
|
41
|
+
fetch('/api/auth/me').then(r => r.json()),
|
|
42
|
+
fetch('/api/settings?type=security').then(r => r.json()),
|
|
43
|
+
fetch('/api/auth/users').then(r => r.json()),
|
|
44
|
+
])
|
|
45
|
+
.then(([userData, secData, usersData]) => {
|
|
46
|
+
if (userData && !userData.error) {
|
|
47
|
+
setCurrentUser(userData)
|
|
48
|
+
}
|
|
49
|
+
setSettings({
|
|
50
|
+
appLockEnabled: secData.appLockEnabled ?? true,
|
|
51
|
+
defaultUserId: secData.defaultUserId || null,
|
|
52
|
+
updatedBy: secData.updatedBy || null,
|
|
53
|
+
updatedAt: secData.updatedAt || null,
|
|
54
|
+
updatedByName: secData.updatedByName || null,
|
|
55
|
+
})
|
|
56
|
+
if (usersData && Array.isArray(usersData)) {
|
|
57
|
+
setUsersList(usersData)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch(e => console.error("Error loading security settings:", e))
|
|
61
|
+
.finally(() => setIsLoading(false))
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const isAdmin = currentUser?.role === "Admin"
|
|
65
|
+
|
|
66
|
+
const handleToggleClick = (newState: boolean) => {
|
|
67
|
+
setPendingLockState(newState)
|
|
68
|
+
setShowConfirmModal(true)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleConfirm = async () => {
|
|
72
|
+
setShowConfirmModal(false)
|
|
73
|
+
setIsSaving(true)
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch('/api/settings', {
|
|
76
|
+
method: 'PUT',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
type: 'security',
|
|
80
|
+
data: { appLockEnabled: pendingLockState, defaultUserId: settings.defaultUserId },
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
setSettings(prev => ({
|
|
85
|
+
...prev,
|
|
86
|
+
appLockEnabled: pendingLockState,
|
|
87
|
+
updatedByName: currentUser?.fullName || null,
|
|
88
|
+
updatedAt: new Date().toISOString(),
|
|
89
|
+
}))
|
|
90
|
+
setSaveSuccess(true)
|
|
91
|
+
setTimeout(() => setSaveSuccess(false), 3000)
|
|
92
|
+
} else {
|
|
93
|
+
const data = await res.json()
|
|
94
|
+
console.error("Failed to save:", data.error)
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error("Error saving security settings:", e)
|
|
98
|
+
} finally {
|
|
99
|
+
setIsSaving(false)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleUserChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
104
|
+
const newUserId = e.target.value
|
|
105
|
+
setSettings(prev => ({ ...prev, defaultUserId: newUserId }))
|
|
106
|
+
setIsSaving(true)
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch('/api/settings', {
|
|
109
|
+
method: 'PUT',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
type: 'security',
|
|
113
|
+
data: { appLockEnabled: settings.appLockEnabled, defaultUserId: newUserId },
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
if (res.ok) {
|
|
117
|
+
setSettings(prev => ({
|
|
118
|
+
...prev,
|
|
119
|
+
updatedByName: currentUser?.fullName || null,
|
|
120
|
+
updatedAt: new Date().toISOString(),
|
|
121
|
+
}))
|
|
122
|
+
setSaveSuccess(true)
|
|
123
|
+
setTimeout(() => setSaveSuccess(false), 3000)
|
|
124
|
+
} else {
|
|
125
|
+
console.error("Failed to save default user")
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("Error saving security settings:", e)
|
|
129
|
+
} finally {
|
|
130
|
+
setIsSaving(false)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const formatDate = (iso: string | null) => {
|
|
135
|
+
if (!iso) return null
|
|
136
|
+
try {
|
|
137
|
+
return new Date(iso).toLocaleDateString('en-US', {
|
|
138
|
+
year: 'numeric',
|
|
139
|
+
month: 'short',
|
|
140
|
+
day: 'numeric',
|
|
141
|
+
hour: '2-digit',
|
|
142
|
+
minute: '2-digit',
|
|
143
|
+
})
|
|
144
|
+
} catch {
|
|
145
|
+
return iso
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isLoading) {
|
|
150
|
+
return (
|
|
151
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
152
|
+
<CardContent className="py-12 text-center">
|
|
153
|
+
<div className="inline-flex items-center gap-2 text-text-muted">
|
|
154
|
+
<div className="w-4 h-4 border-2 border-text-muted border-t-transparent rounded-full animate-spin" />
|
|
155
|
+
Loading security settings...
|
|
156
|
+
</div>
|
|
157
|
+
</CardContent>
|
|
158
|
+
</Card>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Non-admin users see a restricted message
|
|
163
|
+
if (!isAdmin) {
|
|
164
|
+
return (
|
|
165
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
166
|
+
<CardHeader>
|
|
167
|
+
<CardTitle className="text-text-heading flex items-center gap-2">
|
|
168
|
+
<Shield size={20} className="text-amber-500" />
|
|
169
|
+
Security
|
|
170
|
+
</CardTitle>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<CardContent>
|
|
173
|
+
<div className="flex items-center gap-3 p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
|
174
|
+
<Lock size={20} className="text-amber-500 shrink-0" />
|
|
175
|
+
<div>
|
|
176
|
+
<p className="text-sm font-medium text-text-primary">Admin Access Required</p>
|
|
177
|
+
<p className="text-xs text-text-muted mt-0.5">Only administrators can view and change security settings.</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</CardContent>
|
|
181
|
+
</Card>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
{/* Main Security Card */}
|
|
188
|
+
<Card className="bg-bg-surface border-border-primary overflow-hidden">
|
|
189
|
+
<CardHeader className="pb-4">
|
|
190
|
+
<CardTitle className="text-text-heading flex items-center gap-2">
|
|
191
|
+
<Shield size={20} className="text-blue-500" />
|
|
192
|
+
App Lock Mode
|
|
193
|
+
</CardTitle>
|
|
194
|
+
<p className="text-sm text-text-secondary">
|
|
195
|
+
Control whether users need to log in when opening the application.
|
|
196
|
+
</p>
|
|
197
|
+
</CardHeader>
|
|
198
|
+
<CardContent className="space-y-5">
|
|
199
|
+
{/* Current Status */}
|
|
200
|
+
<div className={`
|
|
201
|
+
flex items-center justify-between p-4 rounded-xl border transition-all duration-500
|
|
202
|
+
${settings.appLockEnabled
|
|
203
|
+
? 'bg-emerald-500/5 border-emerald-500/20'
|
|
204
|
+
: 'bg-amber-500/5 border-amber-500/20'
|
|
205
|
+
}
|
|
206
|
+
`}>
|
|
207
|
+
<div className="flex items-center gap-3">
|
|
208
|
+
<div className={`
|
|
209
|
+
p-2.5 rounded-xl transition-all duration-500
|
|
210
|
+
${settings.appLockEnabled
|
|
211
|
+
? 'bg-emerald-500/15 text-emerald-500'
|
|
212
|
+
: 'bg-amber-500/15 text-amber-500'
|
|
213
|
+
}
|
|
214
|
+
`}>
|
|
215
|
+
{settings.appLockEnabled
|
|
216
|
+
? <ShieldCheck size={22} />
|
|
217
|
+
: <ShieldOff size={22} />
|
|
218
|
+
}
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<p className={`text-sm font-semibold ${settings.appLockEnabled ? 'text-emerald-400' : 'text-amber-400'}`}>
|
|
222
|
+
{settings.appLockEnabled ? "App is Locked" : "App is Unlocked"}
|
|
223
|
+
</p>
|
|
224
|
+
<p className="text-xs text-text-muted mt-0.5">
|
|
225
|
+
{settings.appLockEnabled
|
|
226
|
+
? "Login with username & password is required to access the app."
|
|
227
|
+
: "The app opens directly without login — auto-logged in as admin."
|
|
228
|
+
}
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Toggle Button */}
|
|
234
|
+
<button
|
|
235
|
+
onClick={() => handleToggleClick(!settings.appLockEnabled)}
|
|
236
|
+
disabled={isSaving}
|
|
237
|
+
className={`
|
|
238
|
+
relative inline-flex h-7 w-[52px] shrink-0 cursor-pointer items-center rounded-full border-2 transition-all duration-300
|
|
239
|
+
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg-surface
|
|
240
|
+
disabled:cursor-not-allowed disabled:opacity-50
|
|
241
|
+
${settings.appLockEnabled
|
|
242
|
+
? 'bg-emerald-500 border-emerald-500'
|
|
243
|
+
: 'bg-zinc-600 border-zinc-600'
|
|
244
|
+
}
|
|
245
|
+
`}
|
|
246
|
+
role="switch"
|
|
247
|
+
aria-checked={settings.appLockEnabled}
|
|
248
|
+
aria-label="Toggle app lock"
|
|
249
|
+
>
|
|
250
|
+
<span className={`
|
|
251
|
+
pointer-events-none flex items-center justify-center h-5 w-5 rounded-full bg-white shadow-lg transition-all duration-300
|
|
252
|
+
${settings.appLockEnabled ? 'translate-x-[26px]' : 'translate-x-[3px]'}
|
|
253
|
+
`}>
|
|
254
|
+
{settings.appLockEnabled
|
|
255
|
+
? <Lock size={11} className="text-emerald-600" />
|
|
256
|
+
: <Unlock size={11} className="text-zinc-500" />
|
|
257
|
+
}
|
|
258
|
+
</span>
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Unlocked Warning */}
|
|
263
|
+
{!settings.appLockEnabled && (
|
|
264
|
+
<div className="flex gap-3 p-3.5 bg-amber-500/8 border border-amber-500/20 rounded-xl animate-in fade-in slide-in-from-top-2 duration-300">
|
|
265
|
+
<AlertTriangle size={18} className="text-amber-500 shrink-0 mt-0.5" />
|
|
266
|
+
<div>
|
|
267
|
+
<p className="text-sm font-medium text-amber-400">Security Notice</p>
|
|
268
|
+
<p className="text-xs text-text-muted mt-1">
|
|
269
|
+
Anyone with physical access to this workstation can use the application without credentials.
|
|
270
|
+
Only use this mode on trusted, single-user workstations.
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Auto-login User Selection */}
|
|
277
|
+
{!settings.appLockEnabled && (
|
|
278
|
+
<div className="flex flex-col gap-2 p-4 bg-bg-panel border border-border-primary rounded-xl animate-in fade-in slide-in-from-top-2 duration-300">
|
|
279
|
+
<label htmlFor="defaultUserId" className="text-sm font-medium text-text-primary">
|
|
280
|
+
Auto-Login User
|
|
281
|
+
</label>
|
|
282
|
+
<p className="text-xs text-text-muted mb-2">
|
|
283
|
+
Select which user account the application will automatically log into when opened.
|
|
284
|
+
</p>
|
|
285
|
+
<select
|
|
286
|
+
id="defaultUserId"
|
|
287
|
+
value={settings.defaultUserId || ""}
|
|
288
|
+
onChange={handleUserChange}
|
|
289
|
+
disabled={isSaving}
|
|
290
|
+
className="flex h-10 w-full rounded-md border border-border-primary bg-bg-surface px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-50"
|
|
291
|
+
>
|
|
292
|
+
<option value="" disabled>Select a user</option>
|
|
293
|
+
{usersList.map(user => (
|
|
294
|
+
<option key={user.id} value={user.id}>
|
|
295
|
+
{user.fullName} (@{user.username || 'user'}) - {user.role}
|
|
296
|
+
</option>
|
|
297
|
+
))}
|
|
298
|
+
</select>
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{/* Last Changed Info */}
|
|
303
|
+
{settings.updatedAt && (
|
|
304
|
+
<div className="flex items-center gap-4 pt-2 border-t border-border-primary">
|
|
305
|
+
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
306
|
+
<User size={12} />
|
|
307
|
+
<span>Changed by <span className="text-text-secondary font-medium">{settings.updatedByName || 'Unknown'}</span></span>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="flex items-center gap-1.5 text-xs text-text-muted">
|
|
310
|
+
<Clock size={12} />
|
|
311
|
+
<span>{formatDate(settings.updatedAt)}</span>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</CardContent>
|
|
316
|
+
</Card>
|
|
317
|
+
|
|
318
|
+
{/* How It Works Card */}
|
|
319
|
+
<Card className="bg-bg-surface border-border-primary">
|
|
320
|
+
<CardHeader className="pb-3">
|
|
321
|
+
<CardTitle className="text-text-heading text-base flex items-center gap-2">
|
|
322
|
+
How It Works
|
|
323
|
+
</CardTitle>
|
|
324
|
+
</CardHeader>
|
|
325
|
+
<CardContent>
|
|
326
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
327
|
+
<div className="flex gap-3 p-3 rounded-lg bg-white/[0.02]">
|
|
328
|
+
<div className="p-2 rounded-lg bg-emerald-500/10 h-fit">
|
|
329
|
+
<Lock size={16} className="text-emerald-500" />
|
|
330
|
+
</div>
|
|
331
|
+
<div>
|
|
332
|
+
<p className="text-sm font-medium text-text-primary">Locked Mode</p>
|
|
333
|
+
<p className="text-xs text-text-muted mt-1">
|
|
334
|
+
Users must enter their username and password each time they open the app. Best for shared workstations.
|
|
335
|
+
</p>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
<div className="flex gap-3 p-3 rounded-lg bg-white/[0.02]">
|
|
339
|
+
<div className="p-2 rounded-lg bg-amber-500/10 h-fit">
|
|
340
|
+
<Unlock size={16} className="text-amber-500" />
|
|
341
|
+
</div>
|
|
342
|
+
<div>
|
|
343
|
+
<p className="text-sm font-medium text-text-primary">Unlocked Mode</p>
|
|
344
|
+
<p className="text-xs text-text-muted mt-1">
|
|
345
|
+
The app opens directly as the admin user — no login screen. Best for personal, single-user workstations.
|
|
346
|
+
</p>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</CardContent>
|
|
351
|
+
</Card>
|
|
352
|
+
|
|
353
|
+
{/* Save Success Toast */}
|
|
354
|
+
{saveSuccess && (
|
|
355
|
+
<div className="fixed top-6 right-6 z-[100] animate-in fade-in slide-in-from-top-4 duration-300">
|
|
356
|
+
<div className="bg-emerald-600 text-white px-5 py-3 rounded-xl shadow-2xl flex items-center gap-3">
|
|
357
|
+
<ShieldCheck size={20} />
|
|
358
|
+
<span className="font-medium text-sm">
|
|
359
|
+
{settings.appLockEnabled ? "App locked — login required" : "App unlocked — direct access enabled"}
|
|
360
|
+
</span>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{/* Confirmation Modal */}
|
|
366
|
+
{showConfirmModal && (
|
|
367
|
+
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
|
|
368
|
+
<div className="bg-bg-surface border border-border-primary rounded-2xl shadow-2xl max-w-md w-full mx-4 p-6 space-y-5 animate-in zoom-in-95 duration-200">
|
|
369
|
+
<div className="flex items-start gap-4">
|
|
370
|
+
<div className={`p-3 rounded-full shrink-0 ${pendingLockState ? 'bg-emerald-500/10' : 'bg-amber-500/10'}`}>
|
|
371
|
+
{pendingLockState
|
|
372
|
+
? <Lock className="w-6 h-6 text-emerald-500" />
|
|
373
|
+
: <Unlock className="w-6 h-6 text-amber-500" />
|
|
374
|
+
}
|
|
375
|
+
</div>
|
|
376
|
+
<div className="space-y-2">
|
|
377
|
+
<h3 className="text-lg font-semibold text-text-heading">
|
|
378
|
+
{pendingLockState ? "Lock the Application?" : "Unlock the Application?"}
|
|
379
|
+
</h3>
|
|
380
|
+
<p className="text-sm text-text-muted">
|
|
381
|
+
{pendingLockState
|
|
382
|
+
? "All users will need to enter their username and password to access the application."
|
|
383
|
+
: "The application will open directly without a login screen. Anyone with access to this workstation can use the app."
|
|
384
|
+
}
|
|
385
|
+
</p>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
<div className="flex gap-3 justify-end pt-2">
|
|
389
|
+
<Button
|
|
390
|
+
variant="outline"
|
|
391
|
+
className="border-border-card text-text-primary hover:bg-slate-100 dark:hover:bg-white/10 hover:border-slate-300 dark:hover:border-white/20 hover:text-slate-900 dark:hover:text-white transition-all focus:ring-0"
|
|
392
|
+
onClick={() => setShowConfirmModal(false)}
|
|
393
|
+
disabled={isSaving}
|
|
394
|
+
>
|
|
395
|
+
Cancel
|
|
396
|
+
</Button>
|
|
397
|
+
<Button
|
|
398
|
+
onClick={handleConfirm}
|
|
399
|
+
className={`text-white ${pendingLockState
|
|
400
|
+
? 'bg-emerald-600 hover:bg-emerald-700'
|
|
401
|
+
: 'bg-amber-600 hover:bg-amber-700'
|
|
402
|
+
}`}
|
|
403
|
+
disabled={isSaving}
|
|
404
|
+
>
|
|
405
|
+
{isSaving
|
|
406
|
+
? "Saving..."
|
|
407
|
+
: pendingLockState
|
|
408
|
+
? "Yes, Lock App"
|
|
409
|
+
: "Yes, Unlock App"
|
|
410
|
+
}
|
|
411
|
+
</Button>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
</>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
export const Badge = React.forwardRef<
|
|
5
|
+
HTMLDivElement,
|
|
6
|
+
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "secondary" | "destructive" | "outline" }
|
|
7
|
+
>(({ className, variant = "default", ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<div ref={ref} className={cn(
|
|
10
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
11
|
+
variant === "default" && "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
12
|
+
variant === "secondary" && "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
13
|
+
variant === "destructive" && "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
14
|
+
variant === "outline" && "text-foreground",
|
|
15
|
+
className
|
|
16
|
+
)} {...props} />
|
|
17
|
+
)
|
|
18
|
+
});
|
|
19
|
+
Badge.displayName = "Badge";
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
// Button Component
|
|
5
|
+
export interface ButtonProps
|
|
6
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
7
|
+
variant?: "default" | "outline" | "ghost" | "danger" | "success"
|
|
8
|
+
size?: "default" | "sm" | "lg" | "icon"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
12
|
+
({ className, variant = "default", size = "default", ...props }, ref) => {
|
|
13
|
+
return (
|
|
14
|
+
<button
|
|
15
|
+
className={cn(
|
|
16
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
17
|
+
{
|
|
18
|
+
"bg-primary text-text-heading hover:bg-primary-hover": variant === "default",
|
|
19
|
+
"border border-border-primary bg-transparent hover:bg-bg-panel text-text-primary": variant === "outline",
|
|
20
|
+
"hover:bg-bg-panel hover:text-text-heading": variant === "ghost",
|
|
21
|
+
"bg-danger text-text-heading hover:bg-danger/90": variant === "danger",
|
|
22
|
+
"bg-success text-text-heading hover:bg-success/90": variant === "success",
|
|
23
|
+
"h-10 px-4 py-2": size === "default",
|
|
24
|
+
"h-9 rounded-md px-3": size === "sm",
|
|
25
|
+
"h-11 rounded-md px-8": size === "lg",
|
|
26
|
+
"h-10 w-10": size === "icon",
|
|
27
|
+
},
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
ref={ref}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
Button.displayName = "Button"
|
|
37
|
+
|
|
38
|
+
// Input Component
|
|
39
|
+
export interface InputProps
|
|
40
|
+
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
|
41
|
+
|
|
42
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
43
|
+
({ className, type, ...props }, ref) => {
|
|
44
|
+
return (
|
|
45
|
+
<input
|
|
46
|
+
type={type}
|
|
47
|
+
className={cn(
|
|
48
|
+
"flex h-10 w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
49
|
+
className
|
|
50
|
+
)}
|
|
51
|
+
ref={ref}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
Input.displayName = "Input"
|
|
58
|
+
|
|
59
|
+
// Textarea Component
|
|
60
|
+
export interface TextareaProps
|
|
61
|
+
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
|
62
|
+
|
|
63
|
+
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
64
|
+
({ className, ...props }, ref) => {
|
|
65
|
+
return (
|
|
66
|
+
<textarea
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex min-h-[80px] w-full rounded-md border border-border-primary bg-bg-panel px-3 py-2 text-sm text-text-primary ring-offset-background placeholder:text-text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
ref={ref}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
Textarea.displayName = "Textarea"
|
|
78
|
+
|
|
79
|
+
// Label Component
|
|
80
|
+
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
|
81
|
+
({ className, ...props }, ref) => (
|
|
82
|
+
<label
|
|
83
|
+
ref={ref}
|
|
84
|
+
className={cn(
|
|
85
|
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-text-secondary mb-2 block",
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
Label.displayName = "Label"
|
|
93
|
+
|
|
94
|
+
// Card Component
|
|
95
|
+
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
96
|
+
({ className, ...props }, ref) => (
|
|
97
|
+
<div
|
|
98
|
+
ref={ref}
|
|
99
|
+
className={cn(
|
|
100
|
+
"rounded-lg border border-border-primary bg-bg-surface text-text-primary shadow-sm",
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
{...props}
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
Card.displayName = "Card"
|
|
108
|
+
|
|
109
|
+
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
110
|
+
({ className, ...props }, ref) => (
|
|
111
|
+
<div
|
|
112
|
+
ref={ref}
|
|
113
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
CardHeader.displayName = "CardHeader"
|
|
119
|
+
|
|
120
|
+
export const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
121
|
+
({ className, ...props }, ref) => (
|
|
122
|
+
<h3
|
|
123
|
+
ref={ref}
|
|
124
|
+
className={cn(
|
|
125
|
+
"text-lg font-semibold leading-none tracking-tight text-text-heading",
|
|
126
|
+
className
|
|
127
|
+
)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
CardTitle.displayName = "CardTitle"
|
|
133
|
+
|
|
134
|
+
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
135
|
+
({ className, ...props }, ref) => (
|
|
136
|
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
CardContent.displayName = "CardContent"
|
|
140
|
+
|
|
141
|
+
export const Badge = React.forwardRef<
|
|
142
|
+
HTMLDivElement,
|
|
143
|
+
React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "secondary" | "destructive" | "outline" }
|
|
144
|
+
>(({ className, variant = "default", ...props }, ref) => {
|
|
145
|
+
return (
|
|
146
|
+
<div ref={ref} className={cn(
|
|
147
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
148
|
+
variant === "default" && "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
149
|
+
variant === "secondary" && "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
150
|
+
variant === "destructive" && "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
151
|
+
variant === "outline" && "text-foreground",
|
|
152
|
+
className
|
|
153
|
+
)} {...props} />
|
|
154
|
+
)
|
|
155
|
+
});
|
|
156
|
+
Badge.displayName = "Badge";
|