/* Eno.Health Console — user management. Model: identity (type) + tenant membership (role) + practitioner profile + patient↔practitioner assignments. Sub-tabs: All / Practitioners / Patients / Invitations. System accounts are auto-provisioned, owner, and read-only in UI. Mutates window.ENO.USERS in place. */ const U = window.ENO; const USER_TYPES = [ { value: "practitioner", label: "Practitioner" }, { value: "patient", label: "Patient" }, { value: "system", label: "System" }, ]; const CREATE_TYPES = USER_TYPES.filter((t) => t.value !== "system"); const TENANT_ROLES = [ { value: "owner", label: "Owner" }, { value: "regular", label: "Regular" }, { value: "billing", label: "Billing" }, { value: "read_only", label: "Read-only" }, ]; const RELATIONSHIPS = ["Primary", "Physician", "Nutrition", "Coach"]; const LANGUAGES = ["Dutch", "French", "English", "German", "Italian", "Spanish", "Portuguese", "Polish", "Arabic"]; const COUNTRIES = ["Belgium", "Netherlands", "Germany", "France", "Italy", "Spain", "Portugal", "United Kingdom", "Ireland", "United Arab Emirates"]; const GENDERS = ["Female", "Male", "Non-binary", "Prefer not to say"]; const BLOOD_TYPES = ["O+", "O-", "A+", "A-", "B+", "B-", "AB+", "AB-", "Unknown"]; const SMOKER_OPTS = ["Never", "Former", "Yes"]; const uName = (u) => (u.firstname || u.lastname) ? ((u.firstname || "") + " " + (u.lastname || "")).trim() : (u.name || "—"); const uInitials = (u) => uName(u).split(" ").filter(Boolean).map((w) => w[0]).slice(0, 2).join("").toUpperCase() || "?"; const typeLabel = (t) => (USER_TYPES.find((x) => x.value === t) || {}).label || "—"; const typeTone = (t) => (t === "patient" ? "purple" : t === "system" ? "neutral" : "accent"); const roleLabel = (r) => (TENANT_ROLES.find((x) => x.value === r) || {}).label || r; const STATUS_TONE = { active: "ok", disabled: "neutral", invited: "warn", pending: "info" }; const STATUS_LABEL = { active: "active", disabled: "disabled", invited: "invited", pending: "pending" }; const practitionersIn = (tenantId) => U.USERS.filter((u) => u.tenant === tenantId && u.type === "practitioner" && u.status !== "invited"); function ageFromDob(dob) { if (!dob) return null; const d = new Date(dob); if (isNaN(d)) return null; const now = new Date(U.NOW); let a = now.getUTCFullYear() - d.getUTCFullYear(); const m = now.getUTCMonth() - d.getUTCMonth(); if (m < 0 || (m === 0 && now.getUTCDate() < d.getUTCDate())) a--; return a; } let _uidc = 0; const newUserId = () => "usr_" + Date.now().toString(36) + (_uidc++); const SOCIALS = [ { key: "instagram", label: "Instagram", ph: "@handle" }, { key: "linkedin", label: "LinkedIn", ph: "linkedin.com/in/…" }, { key: "facebook", label: "Facebook", ph: "facebook.com/…" }, { key: "tiktok", label: "TikTok", ph: "@handle" }, { key: "x", label: "X", ph: "@handle" }, { key: "website", label: "Website", ph: "https://…" }, ]; const verifyMethodLabel = (m) => (m === "itsme" ? "itsme" : m === "idnow" ? "IDnow" : m || ""); const docsForUser = (userId) => U.docsForPatient ? U.docsForPatient(userId) : U.DOCUMENTS.filter((d) => d.patient === userId || d.patientId === userId || d.initiatedByUserId === userId); const interviewsForUser = (userId) => U.interviewsForPatient ? U.interviewsForPatient(userId) : []; const userAPI = () => window.EnoAdminAPI || null; const fullNameFrom = (u) => ((u.firstname || u.firstName || "") + " " + (u.lastname || u.lastName || "")).trim(); const normalizeUIUser = (u) => { const first = u.firstname || u.firstName || (u.raw && u.raw.first_name) || ""; const last = u.lastname || u.lastName || (u.raw && u.raw.last_name) || ""; const tenant = u.tenant || u.tenantId || (u.raw && u.raw.tenant_id) || ""; return { ...u, tenant, type: u.type || "practitioner", status: u.status || "active", role: u.role || "regular", firstname: first, lastname: last, name: u.name || (first || last ? (first + " " + last).trim() : u.email || "—"), dob: u.dob || u.dateOfBirth || (u.raw && u.raw.date_of_birth) || "", linkedin: u.linkedin || u.linkedInUrl || (u.raw && u.raw.linkedin_url) || "", social: u.social || u.socialProfiles || (u.raw && u.raw.social_profiles) || {}, avatar: u.avatar || u.avatarMediaId || (u.raw && u.raw.avatar_media_id) || "", assignments: (u.assignments || []).map((a) => ({ practitionerId: a.practitionerId || a.practitioner_user_id || "", practitioner_user_id: a.practitioner_user_id || a.practitionerId || "", relationship: a.relationship || "Primary", })), }; }; const upsertUserCache = (user) => { const u = normalizeUIUser(user); const i = U.USERS.findIndex((x) => x.id === u.id); if (i >= 0) U.USERS[i] = { ...U.USERS[i], ...u }; else U.USERS.push(u); return u; }; const syncUserCache = (users, scope) => { const incoming = (users || []).map(normalizeUIUser); const ids = new Set(incoming.map((u) => u.id)); if (scope && scope.tenant) { for (let i = U.USERS.length - 1; i >= 0; i--) if (U.USERS[i].tenant === scope.tenant && !ids.has(U.USERS[i].id)) U.USERS.splice(i, 1); } else if (scope && scope.cell) { const tenantIds = new Set(U.TENANTS.filter((t) => t.cell === scope.cell).map((t) => t.id)); for (let i = U.USERS.length - 1; i >= 0; i--) if (tenantIds.has(U.USERS[i].tenant) && !ids.has(U.USERS[i].id)) U.USERS.splice(i, 1); } incoming.forEach(upsertUserCache); return incoming; }; const userPayload = (u) => ({ role: u.role, status: u.status, first_name: u.firstname || u.firstName || "", last_name: u.lastname || u.lastName || "", email: u.email || "", phone_cell: u.phoneCell || "", phone_office: u.phoneOffice || "", language: u.language || "", languages: u.languages || [], gender: u.gender || "", date_of_birth: u.dob || "", address: u.address || {}, mfa: !!u.mfa, avatar_media_id: u.avatar || "", specialization: u.specialization || "", linkedin_url: u.linkedin || "", bio: u.bio || "", degrees: u.degrees || [], health: u.health || {}, social_profiles: u.social || {}, assignments: (u.assignments || []).map((a) => ({ practitioner_user_id: a.practitionerId || a.practitioner_user_id, relationship: a.relationship || "Primary" })).filter((a) => a.practitioner_user_id), }); const createPayload = (u) => ({ ...userPayload(u), type: u.type, role: u.type === "patient" ? "regular" : (u.role || "regular"), }); const invitePayload = (u) => ({ type: u.type, role: u.type === "patient" ? "regular" : (u.role || "regular"), email: u.email || "", first_name: u.firstname || "", last_name: u.lastname || "", specialization: u.specialization || "", assignments: (u.assignments || []).map((a) => ({ practitioner_user_id: a.practitionerId || a.practitioner_user_id, relationship: a.relationship || "Primary" })).filter((a) => a.practitioner_user_id), note: u.note || "", }); function AvatarImg({ user, size = 28 }) { if (user.avatar) return h("img", { className: "avatar-img", src: user.avatar, alt: "", style: { width: size, height: size } }); return h(Avatar, { initials: uInitials(user), size, tone: user.status === "disabled" ? "bad" : typeTone(user.type) }); } function VerifiedTick({ verification, size = 14 }) { if (!verification || verification.status !== "verified") return null; return h("span", { className: "vtick", title: "Verified via " + verifyMethodLabel(verification.method) }, h(Icon, { name: "verified", size })); } function AvatarUploader({ value, onChange }) { const ref = useRef(); const pick = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onChange(r.result); r.readAsDataURL(file); }; return h("div", { className: "avatar-up" }, h("div", { className: "avatar-up-thumb", onClick: () => ref.current.click() }, value ? h("img", { src: value, alt: "" }) : h(Icon, { name: "camera", size: 24 })), h("input", { ref, type: "file", accept: "image/*", hidden: true, onChange: (e) => pick(e.target.files[0]) }), h("div", { className: "avatar-up-actions" }, h(Button, { variant: "default", size: "sm", icon: "upload", onClick: () => ref.current.click() }, value ? "Change photo" : "Upload photo"), value && h(Button, { variant: "ghost", size: "sm", icon: "trash", onClick: () => onChange("") }, "Remove"))); } function VerificationBlock({ value, onChange, toast }) { const v = value || { status: "unverified" }; const verify = (method) => { onChange({ status: "verified", method, verifiedAt: U.NOW }); toast && toast("Identity verified via " + verifyMethodLabel(method), "ok"); }; if (v.status === "verified") return h("div", { className: "verify-card ok" }, h(Icon, { name: "verified", size: 20 }), h("div", { className: "verify-main" }, h("div", { className: "verify-title" }, "Verified identity"), h("div", { className: "dim small" }, "Confirmed via ", h("strong", null, verifyMethodLabel(v.method)))), h(Button, { variant: "ghost", size: "sm", icon: "x", onClick: () => onChange({ status: "unverified" }) }, "Revoke")); if (v.status === "pending") return h("div", { className: "verify-card pend" }, h(Icon, { name: "clock", size: 18 }), h("div", { className: "verify-main" }, h("div", { className: "verify-title" }, "Verification pending"), h("div", { className: "dim small" }, "Awaiting ", verifyMethodLabel(v.method))), h(Button, { variant: "ghost", size: "sm", icon: "x", onClick: () => onChange({ status: "unverified" }) }, "Cancel")); return h("div", { className: "verify-card" }, h(Icon, { name: "shield", size: 18 }), h("div", { className: "verify-main" }, h("div", { className: "verify-title" }, "Not verified"), h("div", { className: "dim small" }, "Confirm this user's identity")), h("div", { className: "verify-btns" }, h(Button, { variant: "default", size: "sm", onClick: () => verify("itsme") }, "Verify with itsme"), h(Button, { variant: "default", size: "sm", onClick: () => verify("idnow") }, "Verify with IDnow"))); } /* Practitioner degrees — repeatable list */ function DegreesEditor({ value, onChange }) { const list = value || []; const setItem = (i, k, v) => onChange(list.map((d, j) => (j === i ? { ...d, [k]: v } : d))); const add = () => onChange([...list, { title: "", university: "", year: "" }]); return h("div", { className: "field field-span" }, h("div", { className: "degrees-list" }, list.map((d, i) => h("div", { className: "degree-row", key: i }, h("div", { className: "degree-grid" }, h(TRow, { label: i === 0 ? "Degree" : null, value: d.title, onChange: (v) => setItem(i, "title", v), ph: "e.g. MD, Clinical Pathology", span: true }), h(TRow, { label: i === 0 ? "University" : null, value: d.university, onChange: (v) => setItem(i, "university", v), ph: "Institution" }), h(TRow, { label: i === 0 ? "Year" : null, value: d.year, onChange: (v) => setItem(i, "year", v), ph: "Year" })), h("button", { className: "degree-rm", title: "Remove degree", onClick: () => onChange(list.filter((_, j) => j !== i)) }, h(Icon, { name: "trash", size: 15 })))), !list.length && h("div", { className: "dim small", style: { padding: "2px" } }, "No degrees added yet.")), h("div", { style: { marginTop: "10px" } }, h(Button, { variant: "default", size: "sm", icon: "plus", onClick: add }, "Add degree"))); } function SocialEditor({ value, onChange }) { const s = value || {}; return h("div", { className: "uform-grid c2" }, SOCIALS.map((sm) => h(TRow, { key: sm.key, label: sm.label, value: s[sm.key], ph: sm.ph, onChange: (val) => onChange({ ...s, [sm.key]: val }) }))); } /* Languages — autocomplete multi-select with chips */ function LangAutocomplete({ value, onChange }) { const list = value || []; const [q, setQ] = useState(""); const [open, setOpen] = useState(false); const [hl, setHl] = useState(0); const ref = useRef(); useEffect(() => { const f = (e) => ref.current && !ref.current.contains(e.target) && setOpen(false); document.addEventListener("pointerdown", f, true); return () => document.removeEventListener("pointerdown", f, true); }, []); const avail = LANGUAGES.filter((l) => !list.includes(l)); const matches = avail.filter((l) => l.toLowerCase().includes(q.trim().toLowerCase())); const add = (l) => { if (!l) return; onChange([...list, l]); setQ(""); setHl(0); }; const custom = q.trim() && !LANGUAGES.some((l) => l.toLowerCase() === q.trim().toLowerCase()) && !list.some((l) => l.toLowerCase() === q.trim().toLowerCase()); return h("div", { className: "field field-span", ref }, h("span", { className: "field-label" }, "Languages spoken"), h("div", { className: "lang-box" + (open ? " open" : "") }, list.map((l) => h("span", { className: "list-chip", key: l }, h("span", null, l), h("button", { className: "list-chip-x", onClick: () => onChange(list.filter((x) => x !== l)), title: "Remove" }, h(Icon, { name: "x", size: 13 })))), h("input", { className: "lang-input", placeholder: list.length ? "Add another…" : "Search languages…", value: q, onFocus: () => setOpen(true), onChange: (e) => { setQ(e.target.value); setOpen(true); setHl(0); }, onKeyDown: (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setHl((i) => Math.min(i + 1, matches.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHl((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (matches[hl]) add(matches[hl]); else if (custom) add(q.trim()); } else if (e.key === "Backspace" && !q && list.length) onChange(list.slice(0, -1)); } })), open && (matches.length > 0 || custom) && h("div", { className: "lang-menu" }, matches.map((l, i) => h("button", { key: l, className: "lang-opt" + (i === hl ? " hl" : ""), onMouseEnter: () => setHl(i), onClick: () => add(l) }, l)), custom && h("button", { className: "lang-opt lang-opt-add", onClick: () => add(q.trim()) }, h(Icon, { name: "plus", size: 13 }), "Add “", q.trim(), "”"))); } /* Patient → Documents tab */ function PatientDocsTab({ user, toast, onBump }) { const docs = docsForUser(user.id); const act = (label, d, tone) => { toast(label + " · " + d.file, tone); }; const del = (d) => { const i = U.DOCUMENTS.findIndex((x) => x.id === d.id); if (i >= 0) U.DOCUMENTS.splice(i, 1); toast("Deleted " + d.file, "bad"); onBump(); }; const ban = (d) => { d.banned = true; toast("Banned " + d.file, "bad"); onBump(); }; const reprocess = (d) => { toast("Re-processing " + d.file, "ok"); onBump(); }; if (!docs.length) return h(Empty, { icon: "documents", title: "No documents", sub: "No documents are linked to this patient yet." }); return h("div", { className: "ptab" }, h("div", { className: "ptab-head" }, docs.length, " document", docs.length === 1 ? "" : "s", " linked to this patient"), h("div", { className: "doc-cards" }, docs.map((d) => h("div", { className: "doc-card" + (d.banned ? " banned" : ""), key: d.id }, h("div", { className: "doc-card-ic" }, h(Icon, { name: "documents", size: 18 })), h("div", { className: "doc-card-main" }, h("div", { className: "doc-card-name" }, d.file, d.banned && h("span", { className: "ban-tag" }, "Banned")), h("div", { className: "doc-card-meta" }, h(StatusPill, { status: d.status }), h("span", { className: "dim mono small" }, fmtBytes(d.size)), h("span", { className: "dim small" }, "· ", fmtAgo(U.ago(d.createdMin * U.MIN))))), h("div", { className: "doc-card-actions" }, h("button", { className: "mini-btn", title: "Download", onClick: () => act("Downloading", d) }, h(Icon, { name: "download", size: 15 })), h("button", { className: "mini-btn", title: "Re-process", onClick: () => reprocess(d) }, h(Icon, { name: "refresh", size: 15 })), h("button", { className: "mini-btn warn", title: d.banned ? "Banned" : "Ban document", disabled: d.banned, onClick: () => ban(d) }, h(Icon, { name: "lock", size: 15 })), h("button", { className: "mini-btn danger", title: "Delete", onClick: () => del(d) }, h(Icon, { name: "trash", size: 15 }))))))); } /* Patient → Interviews tab */ function fmtDur(sec) { const m = Math.floor(sec / 60), s = sec % 60; return m + ":" + String(s).padStart(2, "0"); } function PatientInterviewsTab({ user, toast }) { const items = interviewsForUser(user.id); if (!items.length) return h(Empty, { icon: "activity", title: "No interviews", sub: "No voice recordings stored for this patient yet." }); return h("div", { className: "ptab" }, h("div", { className: "ptab-head" }, items.length, " stored voice recording", items.length === 1 ? "" : "s"), h("div", { className: "doc-cards" }, items.map((it) => h("div", { className: "doc-card", key: it.id }, h("div", { className: "doc-card-ic audio" }, h(Icon, { name: "activity", size: 18 })), h("div", { className: "doc-card-main" }, h("div", { className: "doc-card-name" }, it.title, h("span", { className: "dim small", style: { fontWeight: 400 } }, " · ", it.topic)), h("div", { className: "doc-card-meta" }, it.status === "transcribed" ? h(Pill, { tone: "ok", soft: true }, "transcribed") : h(Pill, { tone: "info", soft: true }, "processing"), h("span", { className: "dim small" }, fmtDur(it.durationSec)), h("span", { className: "dim small" }, "· ", it.lang), h("span", { className: "dim mono small" }, "· ", fmtBytes(it.sizeBytes)), h("span", { className: "dim small" }, "· ", fmtAgo(U.ago(it.recordedMin * U.MIN))))), h("div", { className: "doc-card-actions" }, h(Button, { variant: "default", size: "sm", icon: "download", onClick: () => toast("Downloading recording · " + it.title) }, "Recording"), h(Button, { variant: "ghost", size: "sm", icon: "documents", disabled: it.status !== "transcribed", onClick: () => toast("Downloading transcription · " + it.title) }, "Transcript")))))); } /* ======================================================================== */ /* CONTEXT MENU */ /* ======================================================================== */ function ContextMenu({ pos, items, onClose }) { const ref = useRef(); useEffect(() => { const close = () => onClose(); window.addEventListener("click", close); window.addEventListener("scroll", close, true); window.addEventListener("resize", close); const esc = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", esc); return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close, true); window.removeEventListener("resize", close); window.removeEventListener("keydown", esc); }; }, []); if (!pos) return null; const x = Math.min(pos.x, window.innerWidth - 220); const y = Math.min(pos.y, window.innerHeight - (items.length * 38 + 16)); return h("div", { className: "ctxmenu", ref, style: { left: x, top: y }, onClick: (e) => e.stopPropagation(), onContextMenu: (e) => e.preventDefault() }, items.map((it, i) => it.sep ? h("div", { className: "ctxmenu-sep", key: "sep" + i }) : h("button", { key: it.label, className: "ctxmenu-item" + (it.danger ? " danger" : ""), disabled: it.disabled, onClick: () => { onClose(); it.onClick && it.onClick(); } }, h(Icon, { name: it.icon, size: 15 }), h("span", null, it.label)))); } /* ======================================================================== */ /* FORM HELPERS */ /* ======================================================================== */ function USection({ title, sub, children }) { return h("div", { className: "uform-sec" }, h("div", { className: "uform-sec-head" }, h("div", { className: "uform-sec-title" }, title), sub && h("div", { className: "uform-sec-sub" }, sub)), children); } function TRow({ label, value, onChange, ph, hint, type, span, disabled }) { return h("label", { className: "field" + (span ? " field-span" : "") }, label && h("span", { className: "field-label" }, label), h("input", { className: "input", type: type || "text", placeholder: ph || "", value: value == null ? "" : value, disabled, onChange: (e) => onChange(e.target.value) }), hint && h("span", { className: "field-hint" }, hint)); } function SRow({ label, value, onChange, options, span, disabled }) { return h("label", { className: "field" + (span ? " field-span" : "") }, label && h("span", { className: "field-label" }, label), h(Select, { value: value || "", onChange, disabled, options: [{ value: "", label: "—" }].concat(options.map((o) => (typeof o === "string" ? { value: o, label: o } : o))) })); } function ListEditor({ label, items, onChange, ph }) { const [draft, setDraft] = useState(""); const list = items || []; const add = () => { if (!draft.trim()) return; onChange([...list, draft.trim()]); setDraft(""); }; return h("div", { className: "field field-span" }, h("span", { className: "field-label" }, label), h("div", { className: "list-editor" }, list.map((it, i) => h("div", { className: "list-chip", key: i }, h("span", null, it), h("button", { className: "list-chip-x", onClick: () => onChange(list.filter((_, j) => j !== i)), title: "Remove" }, h(Icon, { name: "x", size: 13 })))), !list.length && h("span", { className: "dim small" }, "None recorded")), h("div", { className: "list-add" }, h("input", { className: "input", placeholder: ph, value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => e.key === "Enter" && (e.preventDefault(), add()) }), h(Button, { variant: "default", size: "sm", icon: "plus", onClick: add }, "Add"))); } /* Patient → practitioner assignment editor */ function AssignmentEditor({ value, onChange, tenantId }) { const list = value || []; const roster = practitionersIn(tenantId); const aid = (a) => a.practitionerId || a.practitioner_user_id || ""; const avail = roster.filter((p) => !list.some((a) => aid(a) === p.id)); const [pid, setPid] = useState(""); const [rel, setRel] = useState("Primary"); const add = () => { if (!pid) return; onChange([...list, { practitionerId: pid, practitioner_user_id: pid, relationship: rel }]); setPid(""); setRel("Primary"); }; const pracName = (id) => { const p = U.USERS.find((x) => x.id === id); return p ? uName(p) : id; }; return h("div", { className: "field field-span" }, h("span", { className: "field-label" }, "Assigned practitioners"), h("div", { className: "assign-list" }, list.map((a, i) => h("div", { className: "assign-row", key: aid(a) }, h(Avatar, { initials: uInitials(U.USERS.find((x) => x.id === aid(a)) || { name: "?" }), size: 26 }), h("div", { className: "assign-main" }, h("div", { className: "assign-name" }, pracName(aid(a))), h("div", { className: "dim small" }, (U.USERS.find((x) => x.id === aid(a)) || {}).specialization || "Practitioner")), h(Pill, { tone: a.relationship === "Primary" ? "accent" : "neutral", soft: true }, a.relationship), h("button", { className: "list-chip-x", title: "Remove", onClick: () => onChange(list.filter((_, j) => j !== i)) }, h(Icon, { name: "x", size: 14 })))), !list.length && h("div", { className: "dim small", style: { padding: "4px 2px" } }, "No practitioners assigned yet.")), avail.length > 0 && h("div", { className: "assign-add" }, h(Select, { value: pid, onChange: setPid, options: [{ value: "", label: "Select practitioner…" }].concat(avail.map((p) => ({ value: p.id, label: uName(p) + (p.specialization ? " · " + p.specialization : "") }))) }), h(Select, { value: rel, onChange: setRel, options: RELATIONSHIPS }), h(Button, { variant: "default", size: "sm", icon: "plus", onClick: add, disabled: !pid }, "Assign"))); } /* ======================================================================== */ /* SYSTEM ACCOUNT DRAWER (read-only) */ /* ======================================================================== */ function SystemDrawer({ user, onClose }) { return h(Drawer, { open: true, onClose, width: 560, title: "System Account", sub: h("div", { className: "drawer-subline" }, h(Pill, { tone: "neutral" }, "System"), h(Pill, { tone: "ok" }, "active"), h("span", { className: "dim" }, U.tenantName(user.tenant))), footer: h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "ghost", onClick: onClose }, "Close")), }, h("div", { className: "syscard" }, h(Icon, { name: "lock", size: 18 }), h("div", null, h("strong", null, "System-managed account."), " Auto-provisioned when the tenant was created. It owns automated ingestion jobs and default tenant actions for the audit trail. It cannot be edited, disabled, invited, deleted, or assigned as a practitioner.")), h("div", { className: "meta-grid", style: { marginTop: "16px" } }, mRow2("Type", h(Pill, { tone: "neutral", soft: true }, "System")), mRow2("Tenant role", h(Pill, { tone: "accent", soft: true }, "Owner")), mRow2("Status", h(Pill, { tone: "ok", soft: true }, "active")), mRow2("Managed by", "System"), mRow2("Email", h(Mono, null, user.email)), mRow2("User ID", h(Mono, { copy: true }, user.id)))); } function mRow2(k, v) { return h("div", { className: "meta-row" }, h("span", { className: "meta-k" }, k), h("span", { className: "meta-v" }, v)); } /* ======================================================================== */ /* MFA SETUP MODAL */ /* ======================================================================== */ function mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function seedFrom(str) { let hsh = 2166136261; for (let i = 0; i < str.length; i++) { hsh ^= str.charCodeAt(i); hsh = Math.imul(hsh, 16777619); } return hsh >>> 0; } const fmtSecret = (s) => s.match(/.{1,4}/g).join(" "); function FakeQR({ seed, size = 168 }) { const N = 25; const rng = mulberry32(seedFrom(seed)); const cells = []; const isFinder = (r, c) => { const inBox = (br, bc) => r >= br && r < br + 7 && c >= bc && c < bc + 7; return inBox(0, 0) || inBox(0, N - 7) || inBox(N - 7, 0); }; for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) { if (isFinder(r, c)) continue; if (rng() > 0.52) cells.push(h("rect", { key: r + "_" + c, x: c, y: r, width: 1.02, height: 1.02, fill: "#1a1a22" })); } const finder = (x, y) => h("g", { key: x + "_" + y }, h("rect", { x, y, width: 7, height: 7, fill: "#1a1a22" }), h("rect", { x: x + 1, y: y + 1, width: 5, height: 5, fill: "#fff" }), h("rect", { x: x + 2, y: y + 2, width: 3, height: 3, fill: "#1a1a22" })); return h("svg", { className: "qr-svg", width: size, height: size, viewBox: "0 0 " + N + " " + N, shapeRendering: "crispEdges" }, h("rect", { x: 0, y: 0, width: N, height: N, fill: "#fff" }), cells, finder(0, 0), finder(0, N - 7), finder(N - 7, 0)); } function CodeInput({ length = 6, value, onChange }) { const refs = useRef([]); const chars = value.padEnd(length).slice(0, length).split(""); const setAt = (i, ch) => { const arr = value.padEnd(length).split(""); arr[i] = ch; const next = arr.join("").replace(/\s+$/g, ""); onChange(next.slice(0, length)); if (ch && i < length - 1) refs.current[i + 1] && refs.current[i + 1].focus(); }; return h("div", { className: "code-input" }, Array.from({ length }).map((_, i) => h("input", { key: i, ref: (el) => (refs.current[i] = el), className: "code-box", inputMode: "numeric", maxLength: 1, value: chars[i] && chars[i].trim() ? chars[i] : "", onChange: (e) => { const d = e.target.value.replace(/\D/g, "").slice(-1); setAt(i, d); }, onKeyDown: (e) => { if (e.key === "Backspace" && !(chars[i] && chars[i].trim()) && i > 0) refs.current[i - 1] && refs.current[i - 1].focus(); }, onPaste: (e) => { e.preventDefault(); const txt = (e.clipboardData.getData("text") || "").replace(/\D/g, "").slice(0, length); if (txt) onChange(txt); }, }))); } function MfaModal({ open, user, onClose, onConfirm, toast }) { const [step, setStep] = useState("scan"); const [code, setCode] = useState(""); const [showSecret, setShowSecret] = useState(false); const secret = useMemo(() => { const rng = mulberry32(seedFrom((user && user.id || "x") + "|mfa")); const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let s = ""; for (let i = 0; i < 32; i++) s += chars[Math.floor(rng() * chars.length)]; return s; }, [user && user.id]); const recovery = useMemo(() => { const rng = mulberry32(seedFrom((user && user.id || "x") + "|rec")); const block = () => Array.from({ length: 5 }).map(() => "0123456789abcdefghjkmnpqrstuvwxyz"[Math.floor(rng() * 33)]).join(""); return Array.from({ length: 10 }).map(() => block() + "-" + block()); }, [user && user.id]); useEffect(() => { if (open) { setStep("scan"); setCode(""); setShowSecret(false); } }, [open, user && user.id]); if (!open || !user) return null; const otpauth = "otpauth://totp/Eno.Health:" + encodeURIComponent(user.email || uName(user)) + "?secret=" + secret + "&issuer=Eno.Health"; const verify = () => { if (code.length < 6) { toast && toast("Enter the 6-digit code", "bad"); return; } setStep("recovery"); toast && toast("Authenticator verified", "ok"); }; const copy = (text, label) => { try { navigator.clipboard && navigator.clipboard.writeText(text); } catch (e) {} toast && toast(label + " copied"); }; const finish = () => { onConfirm(); onClose(); }; const scanStep = h("div", { className: "mfa-body" }, h("div", { className: "mfa-steps" }, h("span", { className: "mfa-stepdot on" }, "1"), h("span", { className: "mfa-stepline" }), h("span", { className: "mfa-stepdot" }, "2")), h("p", { className: "mfa-lead" }, "Protect ", h("strong", null, uName(user)), "'s account with an authenticator app (Google Authenticator, 1Password, Authy…)."), h("div", { className: "mfa-scan" }, h("div", { className: "mfa-qr" }, h(FakeQR, { seed: otpauth })), h("div", { className: "mfa-scan-side" }, h("div", { className: "mfa-num" }, h("span", { className: "mfa-num-b" }, "1"), "Scan this QR code with your authenticator app"), h("div", { className: "mfa-num" }, h("span", { className: "mfa-num-b" }, "2"), "Enter the 6-digit code it generates below"), h("button", { className: "mfa-secret-toggle", onClick: () => setShowSecret((s) => !s) }, h(Icon, { name: "key", size: 13 }), showSecret ? "Hide setup key" : "Can't scan? Enter key manually"), showSecret && h("div", { className: "mfa-secret" }, h("code", null, fmtSecret(secret)), h("button", { className: "mini-btn", title: "Copy key", onClick: () => copy(secret, "Setup key") }, h(Icon, { name: "copy", size: 14 }))))), h("div", { className: "mfa-verify" }, h("div", { className: "field-label" }, "Verification code"), h(CodeInput, { value: code, onChange: setCode }))); const recoveryStep = h("div", { className: "mfa-body" }, h("div", { className: "mfa-success" }, h("div", { className: "mfa-success-ic" }, h(Icon, { name: "verified", size: 24 })), h("div", null, h("div", { className: "mfa-success-t" }, "Multi-factor authentication enabled"), h("div", { className: "dim small" }, "Save these recovery codes somewhere safe."))), h("div", { className: "mfa-rec-head" }, h(Icon, { name: "alert", size: 14 }), h("span", null, "Each code works ", h("strong", null, "once"), " if the authenticator device is lost. They won't be shown again.")), h("div", { className: "mfa-codes" }, recovery.map((c) => h("code", { key: c, className: "mfa-code" }, c))), h("div", { className: "mfa-rec-actions" }, h(Button, { variant: "default", size: "sm", icon: "copy", onClick: () => copy(recovery.join("\n"), "Recovery codes") }, "Copy codes"), h(Button, { variant: "ghost", size: "sm", icon: "download", onClick: () => copy(recovery.join("\n"), "Recovery codes (download mock)") }, "Download"))); const footer = step === "scan" ? h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "shield", disabled: code.length < 6, onClick: verify }, "Verify & enable")) : h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "primary", icon: "check", onClick: finish }, "Done")); return h(Modal, { open, onClose: step === "scan" ? onClose : finish, width: 520, title: h("span", { className: "mfa-title" }, h(Icon, { name: "smartphone", size: 17 }), "Set up multi-factor auth"), footer }, step === "scan" ? scanStep : recoveryStep); } /* ======================================================================== */ /* USER DETAIL / EDIT DRAWER */ /* ======================================================================== */ function UserDrawer({ user, lockTenant, tenants, onClose, onSave, onAction, toast }) { const [f, setF] = useState(null); const [dtab, setDtab] = useState("profile"); const [mfaSetup, setMfaSetup] = useState(false); useEffect(() => { setF(user ? JSON.parse(JSON.stringify(user)) : null); setDtab("profile"); setMfaSetup(false); }, [user && user.id]); if (!user) return null; if (user.isSystemManaged) return h(SystemDrawer, { user, onClose }); if (!f) return null; const set = (k, v) => setF((p) => ({ ...p, [k]: v })); const setAddr = (k, v) => setF((p) => ({ ...p, address: { ...(p.address || {}), [k]: v } })); const setH = (k, v) => setF((p) => ({ ...p, health: { ...(p.health || {}), [k]: v } })); const setDeg = (k, v) => setF((p) => ({ ...p, degree: { ...(p.degree || {}), [k]: v } })); const setDegrees = (v) => setF((p) => ({ ...p, degrees: v, degree: undefined })); const isPatient = f.type === "patient"; const h2 = f.health || {}; const dg = f.degree || {}; const degrees = f.degrees || (dg.title || dg.university || dg.year ? [dg] : []); const ad = f.address || {}; const invited = f.status === "invited"; const account = h(USection, { title: "Account" }, h("div", { className: "uform-grid c2" }, !isPatient && h(SRow, { label: "Type", value: f.type, onChange: (v) => set("type", v), options: CREATE_TYPES, disabled: true }), !isPatient && h(SRow, { label: "Tenant role", value: f.role, onChange: (v) => set("role", v), options: TENANT_ROLES }), !lockTenant && tenants && h(SRow, { label: "Tenant", value: f.tenant, onChange: (v) => set("tenant", v), options: tenants.map((t) => ({ value: t.id, label: t.name })), span: true }), h(SRow, { label: "Status", value: f.status, onChange: (v) => set("status", v), options: [{ value: "active", label: "Active" }, { value: "disabled", label: "Disabled" }, { value: "pending", label: "Pending identity" }].concat(invited ? [{ value: "invited", label: "Invited" }] : []) }), h("label", { className: "field" }, h("span", { className: "field-label" }, "Multi-factor auth"), h("div", { style: { paddingTop: "4px" } }, h(Toggle, { on: !!f.mfa, onChange: (v) => { if (v) setMfaSetup(true); else set("mfa", false); } }))))); const identity = h(USection, { title: "Identity" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "First name", value: f.firstname, onChange: (v) => set("firstname", v), ph: "First name" }), h(TRow, { label: "Last name", value: f.lastname, onChange: (v) => set("lastname", v), ph: "Last name" }), h(SRow, { label: "Gender", value: f.gender, onChange: (v) => set("gender", v), options: GENDERS }), h(TRow, { label: "Date of birth", value: f.dob, onChange: (v) => set("dob", v), type: "date", hint: ageFromDob(f.dob) != null ? ageFromDob(f.dob) + " years old" : null }))); const contact = h(USection, { title: "Contact" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Email address", value: f.email, onChange: (v) => set("email", v), ph: "name@example.com", type: "email", span: true }), !isPatient && h(TRow, { label: "Phone (office)", value: f.phoneOffice, onChange: (v) => set("phoneOffice", v), ph: "+32 …" }), h(TRow, { label: "Phone (cell)", value: f.phoneCell, onChange: (v) => set("phoneCell", v), ph: "+32 …" }), h(SRow, { label: "Primary language", value: f.language, onChange: (v) => set("language", v), options: LANGUAGES }))); const address = h(USection, { title: "Address" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Street & number", value: ad.line, onChange: (v) => setAddr("line", v), ph: "Street address", span: true }), h(TRow, { label: "City", value: ad.city, onChange: (v) => setAddr("city", v), ph: "City" }), h(TRow, { label: "Postal code", value: ad.postal, onChange: (v) => setAddr("postal", v), ph: "Postal code" }), h(SRow, { label: "Country", value: ad.country, onChange: (v) => setAddr("country", v), options: COUNTRIES, span: true }))); const professional = h(USection, { title: "Professional", sub: "Specialization & credentials" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Specialization", value: f.specialization, onChange: (v) => set("specialization", v), ph: "e.g. Internal Medicine", span: true }), h(TRow, { label: "LinkedIn profile", value: f.linkedin, onChange: (v) => set("linkedin", v), ph: "https://www.linkedin.com/in/…", span: true }), h("label", { className: "field field-span" }, h("span", { className: "field-label" }, "Services description"), h("textarea", { className: "input", rows: 4, value: f.bio || "", onChange: (e) => set("bio", e.target.value), placeholder: "Describe the services and care this practitioner offers…" }))), h("div", { className: "uform-subhead" }, "Degrees"), h(DegreesEditor, { value: degrees, onChange: setDegrees })); const careTeam = h(USection, { title: "Care team", sub: "Practitioners attached to this patient" }, h("div", { className: "uform-grid" }, h(AssignmentEditor, { value: f.assignments, onChange: (v) => set("assignments", v), tenantId: f.tenant }))); const vitals = h(USection, { title: "Health metrics", sub: "Most recent recorded measurements" }, h("div", { className: "uform-grid c3" }, h(TRow, { label: "Height (cm)", value: h2.heightCm, onChange: (v) => setH("heightCm", v), ph: "cm", type: "number" }), h(TRow, { label: "Weight (kg)", value: h2.weightKg, onChange: (v) => setH("weightKg", v), ph: "kg", type: "number" }), h(SRow, { label: "Blood type", value: h2.bloodType, onChange: (v) => setH("bloodType", v), options: BLOOD_TYPES }), h(TRow, { label: "Systolic (mmHg)", value: h2.systolic, onChange: (v) => setH("systolic", v), ph: "mmHg", type: "number" }), h(TRow, { label: "Diastolic (mmHg)", value: h2.diastolic, onChange: (v) => setH("diastolic", v), ph: "mmHg", type: "number" }), h(TRow, { label: "Hip-waist ratio", value: h2.hipWaist, onChange: (v) => setH("hipWaist", v), ph: "e.g. 0.85" }), h(SRow, { label: "Smoker", value: h2.smoker, onChange: (v) => setH("smoker", v), options: SMOKER_OPTS }), h2.smoker === "Yes" && h(TRow, { label: "Smoking since", value: h2.smokingSince, onChange: (v) => setH("smokingSince", v), ph: "e.g. 2010 or age 18" }), h2.smoker === "Former" && h(TRow, { label: "Smoking years (total)", value: h2.smokingYears, onChange: (v) => setH("smokingYears", v), ph: "e.g. 12", type: "number" }), h2.smoker === "Former" && h(TRow, { label: "Quit smoking when?", value: h2.quitSmoking, onChange: (v) => setH("quitSmoking", v), ph: "e.g. 2018 or Mar 2018" }))); const history = h(USection, { title: "Medical history" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Diabetic history", value: h2.diabetic, onChange: (v) => setH("diabetic", v), ph: "e.g. Type 2 (2019) / No history", span: true }), h(TRow, { label: "History of heart / other conditions", value: h2.heartHistory, onChange: (v) => setH("heartHistory", v), ph: "Relevant conditions & family history", span: true }), h(ListEditor, { label: "Allergies & intolerances", items: h2.allergies, onChange: (v) => setH("allergies", v), ph: "Add an allergy or intolerance…" }))); const care = h(USection, { title: "Care & goals" }, h("div", { className: "uform-grid c2" }, h("label", { className: "field field-span" }, h("span", { className: "field-label" }, "Most important health issues or concerns"), h("textarea", { className: "input", rows: 2, value: h2.concerns || "", onChange: (e) => setH("concerns", e.target.value), placeholder: "Primary concerns…" })), h(ListEditor, { label: "Medications & supplements", items: h2.medications, onChange: (v) => setH("medications", v), ph: "Add a medication or supplement…" }), h("label", { className: "field field-span" }, h("span", { className: "field-label" }, "Personal objective of using Eno"), h("textarea", { className: "input", rows: 2, value: h2.objective || "", onChange: (e) => setH("objective", e.target.value), placeholder: "What does this patient want to achieve?" })))); const social = h(USection, { title: "Social profiles", sub: "Public social media handles" }, h(SocialEditor, { value: f.social, onChange: (v) => set("social", v) })); const profileHeader = h("div", { className: "uprofile" }, h(AvatarUploader, { value: f.avatar, onChange: (v) => set("avatar", v) }), h("div", { className: "uprofile-main" }, h("div", { className: "uprofile-name" }, uName(f) || "New user", f.verification && f.verification.status === "verified" && h(VerifiedTick, { verification: f.verification, size: 17 })), h("div", { className: "uprofile-sub" }, typeLabel(f.type), " · ", roleLabel(f.role), " · ", U.tenantName(f.tenant)))); const verification = h(VerificationBlock, { value: f.verification, onChange: (v) => set("verification", v), toast }); const languages = h(USection, { title: "Languages", sub: "Languages this user can communicate in" }, h("div", { className: "uform-grid" }, h(LangAutocomplete, { value: f.languages, onChange: (v) => set("languages", v) }))); const tabPanes = isPatient ? { profile: h(React.Fragment, null, account, languages), identity: h(React.Fragment, null, verification, identity, contact, address), care: careTeam, health: h(React.Fragment, null, vitals, history, care), social: social, documents: h(PatientDocsTab, { user, toast, onBump: () => set("_t", Date.now()) }), interviews: h(PatientInterviewsTab, { user, toast }), } : { profile: h(React.Fragment, null, account, languages), identity: h(React.Fragment, null, verification, identity, contact, address), professional: professional, }; const drawerTabs = isPatient ? [ { key: "profile", label: "Profile" }, { key: "identity", label: "Identity" }, { key: "care", label: "Care team", count: (f.assignments || []).length || undefined }, { key: "health", label: "Health" }, { key: "documents", label: "Documents", count: docsForUser(user.id).length || undefined }, { key: "interviews", label: "Interviews", count: interviewsForUser(user.id).length || undefined }, { key: "social", label: "Social" }, ] : [ { key: "profile", label: "Profile" }, { key: "identity", label: "Identity" }, { key: "professional", label: "Professional" }, ]; const activeTab = tabPanes[dtab] ? dtab : "profile"; const body = h("div", { className: "uform" }, profileHeader, invited && h("div", { className: "syscard warn" }, h(Icon, { name: "external", size: 16 }), h("div", null, h("strong", null, "Invitation pending."), " This user was invited and has not completed signup. Profile fields will be filled in when they accept.")), h("div", { className: "udrawer-tabs" }, h(Tabs, { tabs: drawerTabs, active: activeTab, onChange: setDtab })), h("div", { className: "udrawer-pane" }, tabPanes[activeTab])); const footer = h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "space-between" } }, h("div", { className: "drawer-actions" }, h(Button, { variant: "danger-ghost", size: "sm", icon: "trash", onClick: () => onAction(invited ? "revoke" : "delete", user) }, invited ? "Revoke" : "Delete"), !invited && h(Button, { variant: "ghost", size: "sm", icon: f.status === "disabled" ? "check" : "lock", onClick: () => onAction(f.status === "disabled" ? "enable" : "disable", user) }, f.status === "disabled" ? "Enable" : "Disable")), h("div", { className: "drawer-actions" }, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "check", onClick: () => onSave(f) }, "Save changes"))); return h(React.Fragment, null, h(Drawer, { open: true, onClose, width: 720, footer, title: uName(f), sub: h("div", { className: "drawer-subline" }, h(Pill, { tone: typeTone(f.type) }, typeLabel(f.type)), h(Pill, { tone: STATUS_TONE[f.status] || "neutral" }, STATUS_LABEL[f.status] || f.status), f.verification && f.verification.status === "verified" && h(Pill, { tone: "ok" }, "Verified"), h("span", { className: "dim" }, roleLabel(f.role), " · ", U.tenantName(f.tenant))), }, body), h(MfaModal, { open: mfaSetup, user: f, onClose: () => setMfaSetup(false), onConfirm: () => set("mfa", true), toast })); } /* ======================================================================== */ /* CREATE + INVITE MODALS */ /* ======================================================================== */ function TypeSeg({ value, onChange }) { return h(SRow, { label: "Type", value, onChange, options: CREATE_TYPES }); } function CreateUserModal({ open, onClose, onCreate, tenant, tenants }) { const seed = () => ({ type: "practitioner", firstname: "", lastname: "", email: "", phoneCell: "", role: "regular", tenant: tenant ? tenant.id : (tenants[0] && tenants[0].id), specialization: "", assignments: [], languages: [], status: "active", phoneOffice: "", language: "", gender: "", dob: "", address: { line: "", city: "", postal: "", country: "" }, avatar: "", mfa: false, linkedin: "", bio: "", degrees: [], health: {}, social: {} }); const [f, setF] = useState(seed()); const [busy, setBusy] = useState(false); useEffect(() => { if (open) { setF(seed()); setBusy(false); } }, [open]); const set = (k, v) => setF((p) => ({ ...p, [k]: v })); const setAddr = (k, v) => setF((p) => ({ ...p, address: { ...(p.address || {}), [k]: v } })); const setH = (k, v) => setF((p) => ({ ...p, health: { ...(p.health || {}), [k]: v } })); const switchType = (type) => setF((p) => ({ ...p, type, role: type === "patient" ? "regular" : p.role, assignments: type === "patient" ? (p.assignments || []) : [] })); const create = () => { if (!ok || busy) return; setBusy(true); Promise.resolve(onCreate(f)).catch(() => {}).finally(() => setBusy(false)); }; const ok = f.tenant && f.firstname.trim() && f.lastname.trim() && f.email.trim() && (f.type !== "practitioner" || f.specialization.trim()); const isPatient = f.type === "patient"; const ad = f.address || {}; const h2 = f.health || {}; return h(Modal, { open, onClose, title: "Create user", width: 760, footer: h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "ghost", onClick: onClose, disabled: busy }, "Cancel"), h(Button, { variant: "primary", icon: busy ? "spinner" : "plus", disabled: !ok || busy, onClick: create }, busy ? "Creating…" : "Create user")) }, h("div", { className: "uform" }, h("div", { className: "uprofile" }, h(AvatarUploader, { value: f.avatar, onChange: (v) => set("avatar", v) }), h("div", { className: "uprofile-main" }, h("div", { className: "uprofile-name" }, fullNameFrom(f) || "New user"), h("div", { className: "uprofile-sub" }, typeLabel(f.type), " · ", roleLabel(f.role), " · ", U.tenantName(f.tenant)))), h(USection, { title: "Account" }, h("div", { className: "uform-grid c2" }, h(TypeSeg, { value: f.type, onChange: switchType }), !isPatient && h(SRow, { label: "Tenant role", value: f.role, onChange: (v) => set("role", v), options: TENANT_ROLES }), isPatient && h(SRow, { label: "Tenant role", value: "regular", onChange: () => {}, options: [{ value: "regular", label: "Regular" }], disabled: true }), !tenant && h(SRow, { label: "Tenant", value: f.tenant, onChange: (v) => set("tenant", v), options: tenants.map((t) => ({ value: t.id, label: t.name })), span: true }), h("label", { className: "field" }, h("span", { className: "field-label" }, "Multi-factor auth"), h("div", { style: { paddingTop: "4px" } }, h(Toggle, { on: !!f.mfa, onChange: (v) => set("mfa", v) }))))), h(USection, { title: "Identity" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "First name", value: f.firstname, onChange: (v) => set("firstname", v), ph: "First name" }), h(TRow, { label: "Last name", value: f.lastname, onChange: (v) => set("lastname", v), ph: "Last name" }), h(SRow, { label: "Gender", value: f.gender, onChange: (v) => set("gender", v), options: GENDERS }), h(TRow, { label: "Date of birth", value: f.dob, onChange: (v) => set("dob", v), type: "date", hint: ageFromDob(f.dob) != null ? ageFromDob(f.dob) + " years old" : null }))), h(USection, { title: "Contact" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Email address", value: f.email, onChange: (v) => set("email", v), ph: "name@example.com", type: "email", span: true }), !isPatient && h(TRow, { label: "Phone (office)", value: f.phoneOffice, onChange: (v) => set("phoneOffice", v), ph: "+32 …" }), h(TRow, { label: "Phone (cell)", value: f.phoneCell, onChange: (v) => set("phoneCell", v), ph: "+32 …" }), h(SRow, { label: "Primary language", value: f.language, onChange: (v) => set("language", v), options: LANGUAGES }))), h(USection, { title: "Languages" }, h("div", { className: "uform-grid" }, h(LangAutocomplete, { value: f.languages, onChange: (v) => set("languages", v) }))), h(USection, { title: "Address" }, h("div", { className: "uform-grid c2" }, h(TRow, { label: "Street & number", value: ad.line, onChange: (v) => setAddr("line", v), ph: "Street address", span: true }), h(TRow, { label: "City", value: ad.city, onChange: (v) => setAddr("city", v), ph: "City" }), h(TRow, { label: "Postal code", value: ad.postal, onChange: (v) => setAddr("postal", v), ph: "Postal code" }), h(SRow, { label: "Country", value: ad.country, onChange: (v) => setAddr("country", v), options: COUNTRIES, span: true }))), !isPatient && h(USection, { title: "Professional" }, h("div", { className: "uform-grid" }, h(TRow, { label: "Specialization", value: f.specialization, onChange: (v) => set("specialization", v), ph: "e.g. MD, Functional Practitioner", span: true }))), isPatient && h(USection, { title: "Care team" }, h("div", { className: "uform-grid" }, h(AssignmentEditor, { value: f.assignments, onChange: (v) => set("assignments", v), tenantId: f.tenant }))), isPatient && h(USection, { title: "Health metrics" }, h("div", { className: "uform-grid c3" }, h(TRow, { label: "Height (cm)", value: h2.heightCm, onChange: (v) => setH("heightCm", v), ph: "cm", type: "number" }), h(TRow, { label: "Weight (kg)", value: h2.weightKg, onChange: (v) => setH("weightKg", v), ph: "kg", type: "number" }), h(SRow, { label: "Blood type", value: h2.bloodType, onChange: (v) => setH("bloodType", v), options: BLOOD_TYPES }), h(TRow, { label: "Systolic (mmHg)", value: h2.systolic, onChange: (v) => setH("systolic", v), ph: "mmHg", type: "number" }), h(TRow, { label: "Diastolic (mmHg)", value: h2.diastolic, onChange: (v) => setH("diastolic", v), ph: "mmHg", type: "number" }), h(TRow, { label: "Hip-waist ratio", value: h2.hipWaist, onChange: (v) => setH("hipWaist", v), ph: "e.g. 0.85" }), h(SRow, { label: "Smoker", value: h2.smoker, onChange: (v) => setH("smoker", v), options: SMOKER_OPTS }), h(TRow, { label: "Diabetic history", value: h2.diabetic, onChange: (v) => setH("diabetic", v), ph: "No history / Type 2", span: true }), h(TRow, { label: "Heart / other conditions", value: h2.heartHistory, onChange: (v) => setH("heartHistory", v), ph: "Relevant conditions", span: true }))), isPatient && h(USection, { title: "Social profiles" }, h(SocialEditor, { value: f.social, onChange: (v) => set("social", v) })), h("div", { className: "mform-note" }, h(Icon, { name: "shield", size: 14 }), h("span", null, "Manual create — ", h("strong", null, "no email is sent"), ". The account is created ", h("strong", null, "Active"), " immediately.")))); } function MoveUserModal({ open, user, onClose, onMove }) { const others = U.TENANTS.filter((t) => !user || t.id !== user.tenant); const [dest, setDest] = useState(others[0] ? others[0].id : ""); useEffect(() => { if (open) setDest(others[0] ? others[0].id : ""); }, [open, user && user.id]); if (!open || !user) return null; const destT = U.TENANTS.find((t) => t.id === dest); return h(Modal, { open, onClose, title: "Move user", width: 460, footer: h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "arrowRight", disabled: !dest, onClick: () => onMove(user, dest) }, "Move user")) }, h("div", { className: "uform" }, h("div", { className: "move-from" }, h(AvatarImg, { user, size: 34 }), h("div", null, h("div", { style: { fontWeight: 600, fontSize: "13.5px" } }, uName(user)), h("div", { className: "dim small" }, "Currently in ", h("strong", null, U.tenantName(user.tenant))))), h(SRow, { label: "Move to tenant", value: dest, onChange: setDest, options: others.map((t) => ({ value: t.id, label: t.name })) }), h("div", { className: "mform-note" }, h(Icon, { name: "alert", size: 14 }), h("span", null, "The user keeps their profile and role, but their tenant membership moves to ", h("strong", null, destT ? destT.name : "the selected tenant"), ".")))); } function InviteUserModal({ open, onClose, onInvite, tenant, tenants }) { const seed = () => ({ type: "practitioner", email: "", role: "regular", tenant: tenant ? tenant.id : (tenants[0] && tenants[0].id), specialization: "", assignments: [], note: "" }); const [f, setF] = useState(seed()); useEffect(() => { if (open) setF(seed()); }, [open]); const set = (k, v) => setF((p) => ({ ...p, [k]: v })); const ok = f.email.trim() && (f.type !== "practitioner" || f.specialization.trim()); const isPatient = f.type === "patient"; return h(Modal, { open, onClose, title: "Invite user", width: 500, footer: h("div", { className: "drawer-actions", style: { width: "100%", justifyContent: "flex-end" } }, h(Button, { variant: "ghost", onClick: onClose }, "Cancel"), h(Button, { variant: "primary", icon: "external", disabled: !ok, onClick: () => onInvite(f) }, "Send invite")) }, h("div", { className: "uform" }, h(TypeSeg, { value: f.type, onChange: (v) => set("type", v) }), h(TRow, { label: "Email address", value: f.email, onChange: (v) => set("email", v), ph: "name@example.com", type: "email" }), h("div", { className: "uform-grid c2" }, !tenant && h(SRow, { label: "Tenant", value: f.tenant, onChange: (v) => set("tenant", v), options: tenants.map((t) => ({ value: t.id, label: t.name })), span: true }), h(SRow, { label: "Tenant role", value: f.role, onChange: (v) => set("role", v), options: TENANT_ROLES, span: !!tenant })), !isPatient && h(TRow, { label: "Specialization", value: f.specialization, onChange: (v) => set("specialization", v), ph: "e.g. Internal Medicine" }), isPatient && h("div", { className: "uform-grid" }, h(AssignmentEditor, { value: f.assignments, onChange: (v) => set("assignments", v), tenantId: f.tenant })), h("label", { className: "field" }, h("span", { className: "field-label" }, "Note (optional)"), h("textarea", { className: "input", rows: 2, value: f.note, onChange: (e) => set("note", e.target.value), placeholder: "Add a personal note to the invitation…" })), h("div", { className: "mform-note" }, h(Icon, { name: "external", size: 14 }), h("span", null, "An invitation email is queued. The account stays ", h("strong", null, "Invited"), " until the recipient completes signup.")))); } /* ======================================================================== */ /* USER MANAGER */ /* ======================================================================== */ function UserManager({ tenant, cell, getUsers, showTenantCol, subtitle }) { const toast = useToast(); const api = userAPI(); const [, setTick] = useState(0); const [live, setLive] = useState({ loading: false, error: "" }); const [q, setQ] = useState(""); const [tab, setTab] = useState("all"); const [sel, setSel] = useState(null); const [ctx, setCtx] = useState(null); const [create, setCreate] = useState(false); const [invite, setInvite] = useState(false); const [moveUser, setMoveUser] = useState(null); const bump = () => setTick((t) => t + 1); const tenantsForPicker = tenant ? null : (cell ? U.TENANTS.filter((t) => t.cell === cell.id) : U.TENANTS); const loadUsers = useCallback(async () => { if (!api || (!api.users && !api.tenantUsers)) return; setLive({ loading: true, error: "" }); try { const body = tenant && api.tenantUsers ? await api.tenantUsers(tenant.id) : await api.users({ cell_id: cell && cell.id }); syncUserCache(body.users || [], tenant ? { tenant: tenant.id } : cell ? { cell: cell.id } : {}); setLive({ loading: false, error: "" }); bump(); } catch (err) { setLive({ loading: false, error: err.message || "Unable to load users" }); } }, [api, tenant && tenant.id, cell && cell.id]); useEffect(() => { loadUsers(); }, [loadUsers]); const all = (getUsers ? getUsers() : U.USERS).map(normalizeUIUser); const byTab = tab === "all" ? all : tab === "practitioners" ? all.filter((u) => u.type === "practitioner" && u.status !== "invited") : tab === "patients" ? all.filter((u) => u.type === "patient" && u.status !== "invited") : all.filter((u) => u.status === "invited"); const rows = byTab.filter((u) => !q.trim() || (uName(u) + " " + u.email + " " + typeLabel(u.type) + " " + U.tenantName(u.tenant)).toLowerCase().includes(q.trim().toLowerCase())); const counts = { all: all.length, practitioners: all.filter((u) => u.type === "practitioner" && u.status !== "invited").length, patients: all.filter((u) => u.type === "patient" && u.status !== "invited").length, invitations: all.filter((u) => u.status === "invited").length, }; const tabs = [ { key: "all", label: "All users", count: counts.all }, { key: "practitioners", label: "Practitioners", count: counts.practitioners }, { key: "patients", label: "Patients", count: counts.patients }, { key: "invitations", label: "Invitations", count: counts.invitations }, ]; const openUser = (u) => { setCtx(null); setSel(u); }; const doAction = (action, u) => { if (u.isSystemManaged) { toast("System-managed account — action not allowed", "bad"); return; } if (action === "delete" || action === "revoke") { const finish = () => { const i = U.USERS.findIndex((x) => x.id === u.id); if (i >= 0) U.USERS.splice(i, 1); if (sel && sel.id === u.id) setSel(null); toast(action === "revoke" ? "Invitation revoked" : uName(u) + " deleted", "bad"); bump(); }; if (api && api.patchTenantUser) { api.patchTenantUser(u.tenant, u.id, { status: "deleted" }).then(finish).catch((err) => toast(err.message || "Unable to delete user", "bad")); } else finish(); return; } else if (action === "disable" || action === "enable") { const nextStatus = action === "enable" ? "active" : "disabled"; const finish = (updated) => { const nu = upsertUserCache(updated || { ...u, status: nextStatus }); toast(uName(nu) + (nextStatus === "active" ? " enabled" : " disabled"), nextStatus === "active" ? "ok" : "warn"); if (sel && sel.id === u.id) setSel({ ...nu }); bump(); }; if (api && api.patchTenantUser) api.patchTenantUser(u.tenant, u.id, { status: nextStatus }).then(finish).catch((err) => toast(err.message || "Unable to update user", "bad")); else finish({ ...u, status: nextStatus }); return; } else if (action === "reset") { toast("Password reset link sent to " + u.email); } else if (action === "disableMfa") { const finish = (updated) => { const nu = upsertUserCache(updated || { ...u, mfa: false }); toast("MFA disabled for " + uName(nu), "warn"); if (sel && sel.id === u.id) setSel({ ...nu }); bump(); }; if (api && api.patchTenantUser) api.patchTenantUser(u.tenant, u.id, { mfa: false }).then(finish).catch((err) => toast(err.message || "Unable to update MFA", "bad")); else finish({ ...u, mfa: false }); return; } else if (action === "resend") { toast("Invitation resent to " + u.email); } else if (action === "export") { toast("Exporting " + uName(u) + " (mock)"); } bump(); }; const onRowContext = (e, u) => { e.preventDefault(); setCtx({ pos: { x: e.clientX, y: e.clientY }, user: u }); }; const ctxItems = (u) => { if (u.isSystemManaged) return [{ label: "Open details", icon: "external", onClick: () => openUser(u) }, { sep: true }, { label: "System-managed", icon: "lock", disabled: true }]; if (u.status === "invited") return [ { label: "Open details", icon: "external", onClick: () => openUser(u) }, { sep: true }, { label: "Resend invite", icon: "refresh", onClick: () => doAction("resend", u) }, { label: "Export…", icon: "download", onClick: () => doAction("export", u) }, u.mfa && { label: "Disable MFA", icon: "shield", onClick: () => doAction("disableMfa", u) }, { label: "Move to…", icon: "arrowRight", onClick: () => setMoveUser(u) }, { sep: true }, { label: "Revoke invite", icon: "trash", danger: true, onClick: () => doAction("revoke", u) }, ].filter(Boolean); return [ { label: "Open details", icon: "external", onClick: () => openUser(u) }, { sep: true }, { label: "Export…", icon: "download", onClick: () => doAction("export", u) }, { label: "Password reset", icon: "key", onClick: () => doAction("reset", u) }, u.mfa && { label: "Disable MFA", icon: "shield", onClick: () => doAction("disableMfa", u) }, { label: "Move to…", icon: "arrowRight", onClick: () => setMoveUser(u) }, { label: u.status === "disabled" ? "Enable" : "Disable", icon: u.status === "disabled" ? "check" : "lock", onClick: () => doAction(u.status === "disabled" ? "enable" : "disable", u) }, { sep: true }, { label: "Delete", icon: "trash", danger: true, onClick: () => doAction("delete", u) }, ].filter(Boolean); }; const saveUser = (f) => { const finish = (updated) => { const saved = upsertUserCache(updated || { ...f, name: uName(f) }); setSel(null); bump(); toast("User saved", "ok"); return saved; }; if (api && api.patchTenantUser) { api.patchTenantUser(f.tenant, f.id, userPayload(f)).then(finish).catch((err) => toast(err.message || "Unable to save user", "bad")); return; } finish(f); }; const createUser = (data) => { const local = { id: newUserId(), type: data.type, firstname: data.firstname, lastname: data.lastname, name: (data.firstname + " " + data.lastname).trim(), email: data.email, phoneCell: data.phoneCell || "", phoneOffice: data.phoneOffice || "", tenant: tenant ? tenant.id : data.tenant, role: data.type === "patient" ? "regular" : (data.role || "regular"), status: "active", lastActive: 0, mfa: !!data.mfa, avatar: data.avatar || "", languages: data.languages || [], language: data.language || (data.languages || [])[0] || "", gender: data.gender || "", dob: data.dob || "", address: data.address || { line: "", city: "", postal: "", country: "" } }; if (data.type === "practitioner") { local.specialization = data.specialization || ""; local.linkedin = data.linkedin || ""; local.bio = data.bio || ""; local.degrees = data.degrees || []; } else { local.health = data.health || {}; local.social = data.social || {}; local.assignments = data.assignments || []; } const finish = (created) => { const u = upsertUserCache(created || local); setCreate(false); bump(); setSel(u); toast("User created — complete the profile", "ok"); }; if (api && api.createTenantUser) api.createTenantUser(local.tenant, createPayload(local)).then(finish).catch((err) => toast(err.message || "Unable to create user", "bad")); else finish(local); }; const inviteUser = (data) => { const local = { id: newUserId(), type: data.type, firstname: data.firstname || "", lastname: data.lastname || "", name: data.email.split("@")[0], email: data.email, tenant: tenant ? tenant.id : data.tenant, role: data.role, status: "invited", lastActive: null, mfa: false, language: "", gender: "", dob: "", phoneCell: "", phoneOffice: "", inviteNote: data.note || "", address: { line: "", city: "", postal: "", country: "" } }; if (data.type === "practitioner") { local.specialization = data.specialization || ""; local.degrees = []; } else { local.health = {}; local.assignments = data.assignments || []; } const finish = (invited) => { const u = upsertUserCache(invited || local); setInvite(false); bump(); setTab("invitations"); setSel(u); toast("Invitation sent to " + data.email, "ok"); }; if (api && api.inviteTenantUser) api.inviteTenantUser(local.tenant, invitePayload(local)).then(finish).catch((err) => toast(err.message || "Unable to invite user", "bad")); else finish(local); }; const cols = ["User", showTenantCol && "Tenant", "Type", "Role", "Status", "MFA", tab === "invitations" ? "Invited" : "Last active"].filter(Boolean); return h(React.Fragment, null, h(Card, { pad: false }, h("div", { className: "td-tabs", style: { paddingTop: "10px" } }, h(Tabs, { tabs, active: tab, onChange: setTab })), live.error && h("div", { className: "scope-banner global", style: { margin: "12px 16px 0" } }, h("span", { className: "sb-ic" }, h(Icon, { name: "alert", size: 16 })), h("div", { className: "sb-main" }, h("strong", null, "Live users unavailable."), " Showing local mock data. ", live.error)), h("div", { className: "toolbar" }, h("div", { className: "search" }, h(Icon, { name: "search", size: 16 }), h("input", { className: "search-input", placeholder: "Search name, email, type…", value: q, onChange: (e) => setQ(e.target.value) })), h("span", { className: "toolbar-count" }, live.loading ? "Loading users…" : [rows.length, " users"]), h("div", { className: "toolbar-actions" }, h(Button, { variant: "default", size: "sm", icon: "external", onClick: () => setInvite(true) }, "Invite user"), h(Button, { variant: "primary", size: "sm", icon: "plus", onClick: () => setCreate(true) }, "Create user"))), h("div", { className: "table-wrap flush" }, h("table", { className: "table table-tight" }, h("thead", null, h("tr", null, cols.map((c) => h("th", { key: c }, c)))), h("tbody", null, rows.map((u) => h("tr", { key: u.id, className: "row-click" + (u.isSystemManaged ? " row-system" : ""), onClick: () => openUser(u), onContextMenu: (e) => onRowContext(e, u) }, h("td", null, h("div", { className: "user-cell" }, h(AvatarImg, { user: u, size: 28 }), h("div", null, h("div", { className: "user-name-row" }, uName(u), h(VerifiedTick, { verification: u.verification }), u.isSystemManaged && h("span", { className: "sys-tag" }, h(Icon, { name: "lock", size: 11 }), "System managed")), h("div", { className: "dim mono small" }, u.email)))), showTenantCol && h("td", null, U.tenantName(u.tenant)), h("td", null, h(Pill, { tone: typeTone(u.type), soft: true }, typeLabel(u.type))), h("td", null, h(Pill, { tone: u.role === "owner" ? "accent" : "neutral", soft: true }, roleLabel(u.role))), h("td", null, h(Pill, { tone: STATUS_TONE[u.status] || "neutral", soft: true }, STATUS_LABEL[u.status] || u.status)), h("td", null, u.mfa ? h(Pill, { tone: "ok", soft: true }, "on") : h(Pill, { tone: "warn", soft: true }, "off")), h("td", { className: "dim" }, u.lastActive == null ? (u.status === "invited" ? "pending" : "—") : fmtAgo(U.ago(u.lastActive))))), !rows.length && h("tr", null, h("td", { colSpan: cols.length }, h(Empty, { icon: "tenants", title: "No users", sub: tab === "invitations" ? "No pending invitations." : "Create or invite a user to get started." }))))), h("div", { className: "table-hint" }, h(Icon, { name: "info", size: 13 }), h("span", null, "Click a row to view and edit. Right-click for quick actions.")))), ctx && h(ContextMenu, { pos: ctx.pos, items: ctxItems(ctx.user), onClose: () => setCtx(null) }), h(UserDrawer, { user: sel, lockTenant: !!tenant, tenants: tenantsForPicker, onClose: () => setSel(null), onSave: saveUser, onAction: doAction, toast }), h(CreateUserModal, { open: create, onClose: () => setCreate(false), onCreate: createUser, tenant, tenants: tenantsForPicker || U.TENANTS }), h(InviteUserModal, { open: invite, onClose: () => setInvite(false), onInvite: inviteUser, tenant, tenants: tenantsForPicker || U.TENANTS }), h(MoveUserModal, { open: !!moveUser, user: moveUser, onClose: () => setMoveUser(null), onMove: (u, dest) => { u.tenant = dest; upsertUserCache(u); setMoveUser(null); if (sel && sel.id === u.id) setSel({ ...u }); bump(); toast(uName(u) + " moved to " + U.tenantName(dest), api ? "warn" : "ok"); } })); } Object.assign(window, { UserManager, USER_TYPES, uName, uInitials, typeLabel });