Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c3d3940
chore(testing): update dev SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 15, 2026
c9190f0
chore: absorb test version bump [proxy-smart-releaser]
proxy-smart-releaser[bot] May 15, 2026
2f54c79
chore(testing): update dev SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 15, 2026
714d6cd
chore(testing): update beta SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 15, 2026
22aa812
chore(testing): update dev SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 15, 2026
5a3c5aa
fix(dicomweb): add wildcard param to schema on server-scoped and bulk…
quotentiroler May 17, 2026
5998e2d
feat(patient-portal): add practitioner role detection and approval wo…
quotentiroler May 17, 2026
2925aa7
chore: sync package versions
quotentiroler May 17, 2026
13964d3
πŸ”„ Update version to 0.1.1-alpha.202605171634.2925aa76 (alpha) [skip ci]
github-actions[bot] May 17, 2026
62b1640
Merge pull request #717 from Max-Health-Inc/develop
proxy-smart-releaser[bot] May 17, 2026
ed6cae9
chore: absorb main branch (resolve version conflicts) [proxy-smart-re…
proxy-smart-releaser[bot] May 17, 2026
6967bb6
πŸ”„ Update version to 0.1.1-beta.202605171634.2925aa76 (beta) [skip ci]
github-actions[bot] May 17, 2026
3fd7869
chore(testing): update dev SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 17, 2026
89ba7cf
chore: absorb test version bump [proxy-smart-releaser]
proxy-smart-releaser[bot] May 17, 2026
3ebe771
chore(testing): update dev SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 17, 2026
b57e9ff
chore(testing): update beta SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 17, 2026
66c7cc2
fix(beta): Caddy broker path glob only matched single segment
quotentiroler May 17, 2026
fecf79c
Merge pull request #719 from Max-Health-Inc/develop
quotentiroler May 17, 2026
7655358
docs: update CHANGELOG.md for PR #719 [skip ci]
github-actions[bot] May 17, 2026
4958a96
chore(testing): update beta SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 17, 2026
8f0f641
chore(testing): update production SMART compliance report [skip ci]
proxy-smart-releaser[bot] May 18, 2026
bfa7c43
feat(backend): add multi-tenant isolation for FHIR proxy
quotentiroler May 17, 2026
cbc38f7
fix(beta): Caddy ** glob-star is not valid β€” use explicit depth patterns
quotentiroler May 19, 2026
7231879
chore: sync package versions
quotentiroler May 19, 2026
c50a966
πŸ”„ Update version to 0.1.1-alpha.202605191043.72318795 (alpha) [skip ci]
github-actions[bot] May 19, 2026
dd5f0aa
Merge pull request #720 from Max-Health-Inc/develop
proxy-smart-releaser[bot] May 19, 2026
486b7f4
πŸ”„ Update version to 0.1.1-beta.202605191043.72318795 (beta) [skip ci]
github-actions[bot] May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.1.1-beta.202605171634.2925aa76] - 2026-05-17

- πŸ”§ Chores & Improvements: Improve Keycloak path matching in docker-compose.beta.yml (broaden /auth/realms/*/broker/* to /auth/realms/*/broker/** with clarifying comment).

**Full Changelog**: https://github.com/Max-Health-Inc/proxy-smart/pull/719


## [0.1.0-alpha.202605142141.3f719102] - 2026-05-14

- πŸ”§ Chores & Improvements: Update Docker setup and non-root execution
Expand Down
2 changes: 1 addition & 1 deletion apps/consent-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Consent App",
"description": "SMART on FHIR consent management app β€” manage FHIR Consent resources for linked Patient records.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite --port 5174",
Expand Down
2 changes: 1 addition & 1 deletion apps/dtr-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart DTR App",
"description": "Da Vinci DTR (Documentation Templates & Rules) β€” SMART on FHIR app for prior authorization documentation, questionnaire rendering, and CQL-based auto-population.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite --port 5175",
Expand Down
2 changes: 1 addition & 1 deletion apps/patient-picker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Patient Picker",
"description": "Patient selection UI shown during SMART standalone launch when patient context is needed.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite --port 5176",
Expand Down
2 changes: 1 addition & 1 deletion apps/patient-portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Patient Portal",
"description": "International Patient Portal β€” SMART on FHIR app for patient access using IPS (International Patient Summary) and IPA (International Patient Access) standards.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite --port 5176",
Expand Down
2 changes: 1 addition & 1 deletion apps/patient-portal/package/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hl7.fhir.uv.ips-generated",
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"description": "Generated TypeScript interfaces for hl7.fhir.uv.ips",
"type": "module",
"main": "index.js",
Expand Down
3 changes: 3 additions & 0 deletions apps/patient-portal/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { PatientScribe } from "@/components/PatientScribe"
import { DicomUpload } from "@/components/DicomUpload"
import { PrescriptionsCard, DevicesCard } from "@/components/PrescriptionsDevicesCards"
import { RecordDetailModal, isResourceVerified } from "@/components/RecordDetailModal"
import { useUserRole } from "@/lib/use-user-role"
import { ShareQRDialog } from "@/components/ShareQRDialog"
import { MedicalTimeline } from "@/components/MedicalTimeline"
import { checkPacsStatus } from "@/lib/dicomweb"
Expand All @@ -78,6 +79,7 @@ interface DashboardProps {
export function Dashboard({ readOnly = false, patientId: overridePatientId }: DashboardProps) {
const { t } = useTranslation()
const { translateCoding } = useFhirTranslation()
const { isPractitioner } = useUserRole()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
Expand Down Expand Up @@ -781,6 +783,7 @@ export function Dashboard({ readOnly = false, patientId: overridePatientId }: Da
title={detailTitle}
resource={detailResource}
documents={documents}
isPractitioner={isPractitioner}
onResourceUpdated={readOnly ? undefined : refreshData}
onResourceDeleted={readOnly ? undefined : refreshData}
/>
Expand Down
55 changes: 51 additions & 4 deletions apps/patient-portal/src/components/RecordDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Badge, Button,
} from "@proxy-smart/shared-ui"
import { useState } from "react"
import { ShieldCheck, ShieldAlert, Pencil, Trash2, Undo2 } from "lucide-react"
import { ShieldCheck, ShieldAlert, Pencil, Trash2, Undo2, CheckCircle2 } from "lucide-react"
import type { DocumentReference, DynamicFhirResource } from "@/lib/fhir-client"
import { deleteResource, updateResource } from "@/lib/fhir-client"
import { isValidConditionVerStatusCode, type ConditionVerStatusCode } from "hl7.fhir.uv.ips-generated/valuesets/ValueSet-ConditionVerStatus"
Expand All @@ -24,6 +24,8 @@ export interface RecordDetailModalProps {
documents?: DocumentReference[]
onResourceUpdated?: (updated: FhirResource) => void
onResourceDeleted?: () => void
/** When true, enables practitioner-only actions (approve, unrestricted delete) */
isPractitioner?: boolean
}

// ── Verification helpers ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -54,23 +56,56 @@ const EDITABLE_TYPES = new Set([
// ── Component ────────────────────────────────────────────────────────────────

export function RecordDetailModal({
open, onOpenChange, title, resource, documents, onResourceUpdated, onResourceDeleted,
open, onOpenChange, title, resource, documents, onResourceUpdated, onResourceDeleted, isPractitioner = false,
}: RecordDetailModalProps) {
const [editOpen, setEditOpen] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [approving, setApproving] = useState(false)
const { t } = useTranslation()
if (!resource) return null

const verification = extractVerificationStatus(resource)
const resourceType = resource.resourceType as string | undefined
const resourceId = resource.id as string | undefined
const canEdit = onResourceUpdated && resourceType && EDITABLE_TYPES.has(resourceType)
// Patients can only delete unverified resources; practitioners can delete any editable resource
const canDelete = onResourceDeleted && resourceType && resourceId
&& EDITABLE_TYPES.has(resourceType) && (!verification || !verification.verified)
&& EDITABLE_TYPES.has(resourceType)
&& (isPractitioner || (!verification || !verification.verified))
// Practitioners can approve provisional resources
const canApprove = isPractitioner && onResourceUpdated && verification && !verification.verified
&& resourceType && EDITABLE_TYPES.has(resourceType)
const hasSnapshot = ((resource.extension as Array<{ url: string }>) ?? []).some(e => e.url === ORIGINAL_SNAPSHOT_EXT)
const fields = buildDetailFields(resource, t, documents)

async function handleApprove() {
if (!resource || !onResourceUpdated) return
setApproving(true)
try {
// Upgrade verification status to confirmed and remove the snapshot extension
const updated = {
...resource,
verificationStatus: {
coding: [{
system: resource.verificationStatus?.coding?.[0]?.system
?? "http://terminology.hl7.org/CodeSystem/condition-ver-status",
code: "confirmed",
display: "Confirmed",
}],
text: "Confirmed",
},
// Remove the original snapshot extension β€” changes are now approved
extension: ((resource.extension as Array<{ url: string }>) ?? [])
.filter(e => e.url !== ORIGINAL_SNAPSHOT_EXT),
}
const result = await updateResource(updated)
onResourceUpdated(result)
onOpenChange(false)
} catch { /* keep modal open */ }
finally { setApproving(false) }
}

async function handleDelete() {
if (!resource || !resourceType || !resourceId) return
setDeleting(true)
Expand Down Expand Up @@ -114,10 +149,21 @@ export function RecordDetailModal({
{resourceId && <span className="text-xs font-mono text-muted-foreground"> / {resourceId}</span>}
</DialogDescription>
{canEdit && (
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 mt-1 flex-wrap">
<Button variant="outline" size="sm" className="w-fit" onClick={() => setEditOpen(true)}>
<Pencil className="size-3.5 mr-1" />{t("recordDetail.editRecord")}
</Button>
{canApprove && (
<Button
variant="outline" size="sm"
className="w-fit text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/20"
onClick={handleApprove}
disabled={approving}
>
<CheckCircle2 className="size-3.5 mr-1" />
{approving ? t("recordDetail.approving", "Approving…") : t("recordDetail.approve", "Approve")}
</Button>
)}
{canDelete && !confirmDelete && (
<Button variant="outline" size="sm" className="w-fit text-destructive hover:bg-destructive/10" onClick={() => setConfirmDelete(true)}>
{hasSnapshot
Expand Down Expand Up @@ -158,6 +204,7 @@ export function RecordDetailModal({
open={editOpen}
onOpenChange={setEditOpen}
resource={resource}
isPractitioner={isPractitioner}
onSaved={(updated) => {
onResourceUpdated(updated)
setEditOpen(false)
Expand Down
28 changes: 16 additions & 12 deletions apps/patient-portal/src/components/RecordEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface RecordEditModalProps {
resource: FhirResource | null
/** Called after successful save with the updated resource from the server */
onSaved: (updated: FhirResource) => void
/** When true, edits bypass the provisional downgrade (practitioner workflow) */
isPractitioner?: boolean
}

// ── Editable field definitions per resource type ─────────────────────────────
Expand Down Expand Up @@ -79,7 +81,7 @@ const EDITABLE_FIELDS: Record<string, EditableField[]> = {

function getByPath(obj: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((acc, key) => {
if (acc == null || typeof acc !== "object") return undefined
if (acc === null || acc === undefined || typeof acc !== "object") return undefined
return (acc as Record<string, unknown>)[key]
}, obj)
}
Expand All @@ -90,15 +92,15 @@ function setByPath(obj: FhirResource, path: string, value: unknown): FhirResourc
let current: unknown = clone
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i]
if (current == null || typeof current !== "object") return clone
if (current === null || current === undefined || typeof current !== "object") return clone
const next = (current as FhirResource)[k]
if (next == null || typeof next !== "object") {
if (next === null || next === undefined || typeof next !== "object") {
const nextKey = keys[i + 1]
;(current as FhirResource)[k] = /^\d+$/.test(nextKey) ? [] : {}
}
current = (current as FhirResource)[k]
}
if (current != null && typeof current === "object") {
if (current !== null && current !== undefined && typeof current === "object") {
(current as FhirResource)[keys[keys.length - 1]] = value
}
return clone
Expand Down Expand Up @@ -146,7 +148,7 @@ function markAsPendingReview(resource: FhirResource, originalResource: FhirResou

// ── Component ────────────────────────────────────────────────────────────────

export function RecordEditModal({ open, onOpenChange, resource, onSaved }: RecordEditModalProps) {
export function RecordEditModal({ open, onOpenChange, resource, onSaved, isPractitioner = false }: RecordEditModalProps) {
const [editValues, setEditValues] = useState<Record<string, string>>(() => {
if (!resource) return {}
const rt = resource.resourceType as string | undefined
Expand All @@ -155,7 +157,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
const vals: Record<string, string> = {}
for (const f of flds) {
const current = getByPath(resource, f.path)
vals[f.path] = current != null ? String(current) : ""
vals[f.path] = current !== null && current !== undefined ? String(current) : ""
}
return vals
})
Expand All @@ -165,6 +167,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
const resourceType = resource?.resourceType as string | undefined
const fields = resourceType ? (EDITABLE_FIELDS[resourceType] ?? []) : []
const wasVerified = resource ? isVerified(resource) : false
const willDowngrade = wasVerified && !isPractitioner
const { t } = useTranslation()

const handleSave = useCallback(async () => {
Expand All @@ -183,8 +186,9 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
updated = setByPath(updated, f.path, coerced)
}

// If the resource was verified, mark as provisional (pending review)
if (wasVerified) {
// Patient edits to verified resources β†’ mark as provisional (pending review)
// Practitioner edits keep the resource verified (no approval needed)
if (willDowngrade) {
updated = markAsPendingReview(updated, resource)
}

Expand All @@ -196,7 +200,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
} finally {
setSaving(false)
}
}, [resource, fields, editValues, wasVerified, onSaved, onOpenChange])
}, [resource, fields, editValues, willDowngrade, onSaved, onOpenChange])

if (!resource || !fields.length) return null

Expand All @@ -209,7 +213,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
{t("recordEdit.editTitle", { resourceType })}
</DialogTitle>
<DialogDescription>
{wasVerified && (
{willDowngrade && (
<span className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 mt-1">
<AlertTriangle className="size-3.5" />
{t("recordEdit.verifiedWarning")}
Expand All @@ -219,7 +223,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
</DialogHeader>

<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto">
{wasVerified && (
{willDowngrade && (
<Badge variant="secondary" className="text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
{t("recordEdit.pendingReview")}
</Badge>
Expand Down Expand Up @@ -254,7 +258,7 @@ export function RecordEditModal({ open, onOpenChange, resource, onSaved }: Recor
) : (
<>
<Save className="size-4 mr-1" />
{wasVerified ? t("recordEdit.saveAsProvisional") : t("recordEdit.saveChanges")}
{willDowngrade ? t("recordEdit.saveAsProvisional") : t("recordEdit.saveChanges")}
</>
)}
</Button>
Expand Down
54 changes: 54 additions & 0 deletions apps/patient-portal/src/lib/use-user-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useMemo } from "react"
import { smartAuth } from "@/lib/smart-auth"

export type FhirUserType = "Patient" | "Practitioner" | "RelatedPerson" | "Person" | null

export interface UserRole {
/** The full fhirUser reference (e.g. "Practitioner/abc-123") */
fhirUser: string | null
/** Extracted resource type from fhirUser */
userType: FhirUserType
/** Whether the current user is a Practitioner */
isPractitioner: boolean
/** Whether the current user is a Patient */
isPatient: boolean
/** The resource ID portion of the fhirUser reference */
userId: string | null
}

const FHIR_USER_TYPES = ["Patient", "Practitioner", "RelatedPerson", "Person"] as const

function parseFhirUser(fhirUser: string | undefined | null): { type: FhirUserType; id: string | null } {
if (!fhirUser) return { type: null, id: null }

// Absolute URL: https://fhir.example.com/Patient/123
const absMatch = fhirUser.match(/\/(Patient|Practitioner|RelatedPerson|Person)\/([^/]+)$/)
if (absMatch) return { type: absMatch[1] as FhirUserType, id: absMatch[2] }

// Relative reference: Practitioner/123
for (const t of FHIR_USER_TYPES) {
if (fhirUser.startsWith(`${t}/`)) {
return { type: t as FhirUserType, id: fhirUser.slice(t.length + 1) }
}
}

return { type: null, id: null }
}

/**
* Hook that derives the user's role from the SMART token's fhirUser claim.
* Re-evaluates only when the token changes (memoized).
*/
export function useUserRole(): UserRole {
const token = smartAuth.getToken()
return useMemo(() => {
const { type, id } = parseFhirUser(token?.fhirUser)
return {
fhirUser: token?.fhirUser ?? null,
userType: type,
isPractitioner: type === "Practitioner",
isPatient: type === "Patient" || type === null, // default to patient if unknown
userId: id,
}
}, [token?.fhirUser])
}
2 changes: 1 addition & 1 deletion apps/smart-dicom-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "SMART DICOM Algorithm Template",
"description": "Starter kit for building SMART on FHIR imaging algorithm apps. Clone, implement your algorithm in src/algorithm.ts, and deploy as a SMART app.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite --port 5180",
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Admin UI",
"description": "A web-based administration interface for managing healthcare applications and resources via Proxy Smart.",
"private": true,
"version": "0.1.0-alpha.202605150936.c7b6b88c",
"version": "0.1.1-beta.202605171634.2925aa76",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "proxy-smart-backend",
"displayName": "Proxy Smart Backend",
"version": "0.1.0-RELEASE.202605151033.0e17e030",
"version": "0.1.1-beta.202605191043.72318795",
"type": "module",
"scripts": {
"test": "bun test",
Expand Down
Loading