@@ -2,27 +2,224 @@ import { useState, useEffect } from 'react';
import { api } from '../utils/api.js' ;
import { useToast } from '../contexts/ToastContext.jsx' ;
export default function SettingsModal ( { onClose , onFeaturesChanged } ) {
// ── Helpers ───────────────────────────────────────────────────────────────────
const APP _TYPES = {
'JAMA-Chat' : { label : 'JAMA-Chat' , desc : 'Chat only. No Branding, Group Manager or Schedule Manager.' } ,
'JAMA-Brand' : { label : 'JAMA-Brand' , desc : 'Chat and Branding.' } ,
'JAMA-Team' : { label : 'JAMA-Team' , desc : 'Chat, Branding, Group Manager and Schedule Manager.' } ,
} ;
// ── Team Management Tab ───────────────────────────────────────────────────────
function TeamManagementTab ( { features } ) {
const toast = useToast ( ) ;
const [ userGroups , setUserGroups ] = useState ( [ ] ) ;
const [ groupManagers , setGroupManagers ] = useState ( [ ] ) ;
const [ scheduleManagers , setScheduleManagers ] = useState ( [ ] ) ;
const [ saving , setSaving ] = useState ( false ) ;
useEffect ( ( ) => {
api . getUserGroups ( ) . then ( ( { groups } ) => setUserGroups ( groups || [ ] ) ) . catch ( ( ) => { } ) ;
api . getSettings ( ) . then ( ( { settings } ) => {
setGroupManagers ( JSON . parse ( settings . team _group _managers || '[]' ) ) ;
setScheduleManagers ( JSON . parse ( settings . team _schedule _managers || '[]' ) ) ;
} ) . catch ( ( ) => { } ) ;
} , [ ] ) ;
const toggle = ( id , list , setList ) => {
setList ( prev => prev . includes ( id ) ? prev . filter ( x => x !== id ) : [ ... prev , id ] ) ;
} ;
const handleSave = async ( ) => {
setSaving ( true ) ;
try {
await api . updateTeamSettings ( { groupManagers , scheduleManagers } ) ;
toast ( 'Team settings saved' , 'success' ) ;
window . dispatchEvent ( new Event ( 'jama:settings-changed' ) ) ;
} catch ( e ) { toast ( e . message , 'error' ) ; }
finally { setSaving ( false ) ; }
} ;
const GroupSelectList = ( { title , description , selected , onToggle } ) => (
< div style = { { marginBottom : 24 } } >
< div className = "settings-section-label" > { title } < / div >
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginBottom : 10 } } > { description } < / p >
{ userGroups . length === 0 ? (
< p style = { { fontSize : 13 , color : 'var(--text-tertiary)' } } > No user groups created yet . Create groups in the Group Manager first . < / p >
) : (
< div style = { { border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , overflow : 'hidden' } } >
{ userGroups . map ( g => (
< label key = { g . id } style = { { display : 'flex' , alignItems : 'center' , gap : 10 , padding : '9px 14px' , borderBottom : '1px solid var(--border)' , cursor : 'pointer' } } >
< input type = "checkbox" checked = { selected . includes ( g . id ) } onChange = { ( ) => onToggle ( g . id ) }
style = { { accentColor : 'var(--primary)' , width : 15 , height : 15 } } / >
< div style = { { width : 24 , height : 24 , borderRadius : 5 , background : 'var(--primary)' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , color : 'white' , fontSize : 9 , fontWeight : 700 , flexShrink : 0 } } > UG < / div >
< span style = { { flex : 1 , fontSize : 14 } } > { g . name } < / span >
< span style = { { fontSize : 12 , color : 'var(--text-tertiary)' } } > { g . member _count } member { g . member _count !== 1 ? 's' : '' } < / span >
< / label >
) ) }
< / div >
) }
{ selected . length === 0 && (
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 6 } } > No groups selected — admins only . < / p >
) }
< / div >
) ;
return (
< div >
< GroupSelectList
title = "Group Managers"
description = "Members of selected groups can access the Group Manager tool."
selected = { groupManagers }
onToggle = { id => toggle ( id , groupManagers , setGroupManagers ) }
/ >
< GroupSelectList
title = "Schedule Managers"
description = "Members of selected groups can access the Schedule Manager tool."
selected = { scheduleManagers }
onToggle = { id => toggle ( id , scheduleManagers , setScheduleManagers ) }
/ >
< button className = "btn btn-primary" onClick = { handleSave } disabled = { saving } >
{ saving ? 'Saving…' : 'Save Team Settings' }
< / button >
< / div >
) ;
}
// ── Registration Tab ──────────────────────────────────────────────────────────
function RegistrationTab ( { onFeaturesChanged } ) {
const toast = useToast ( ) ;
const [ settings , setSettings ] = useState ( { } ) ;
const [ regCode , setRegCode ] = useState ( '' ) ;
const [ regLoading , setRegLoading ] = useState ( false ) ;
const [ copied , setCopied ] = useState ( false ) ;
useEffect ( ( ) => {
api . getSettings ( ) . then ( ( { settings } ) => setSettings ( settings ) ) . catch ( ( ) => { } ) ;
} , [ ] ) ;
const appType = settings . app _type || 'JAMA-Chat' ;
const activeCode = settings . registration _code || '' ;
const adminEmail = settings . admin _email || '—' ;
// Placeholder serial number derived from hostname
const serialNumber = btoa ( window . location . hostname ) . replace ( /[^A-Z0-9]/gi , '' ) . toUpperCase ( ) . slice ( 0 , 16 ) . padEnd ( 16 , '0' ) ;
const handleCopySerial = async ( ) => {
await navigator . clipboard . writeText ( serialNumber ) . catch ( ( ) => { } ) ;
setCopied ( true ) ;
setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
} ;
const handleRegister = async ( ) => {
if ( ! regCode . trim ( ) ) return toast ( 'Enter a registration code' , 'error' ) ;
setRegLoading ( true ) ;
try {
const { features : f } = await api . registerCode ( regCode . trim ( ) ) ;
setRegCode ( '' ) ;
const fresh = await api . getSettings ( ) ;
setSettings ( fresh . settings ) ;
toast ( 'Registration applied successfully.' , 'success' ) ;
window . dispatchEvent ( new Event ( 'jama:settings-changed' ) ) ;
onFeaturesChanged && onFeaturesChanged ( f ) ;
} catch ( e ) { toast ( e . message || 'Invalid registration code' , 'error' ) ; }
finally { setRegLoading ( false ) ; }
} ;
const handleClear = async ( ) => {
try {
const { features : f } = await api . registerCode ( '' ) ;
const fresh = await api . getSettings ( ) ;
setSettings ( fresh . settings ) ;
toast ( 'Registration cleared.' , 'success' ) ;
window . dispatchEvent ( new Event ( 'jama:settings-changed' ) ) ;
onFeaturesChanged && onFeaturesChanged ( f ) ;
} catch ( e ) { toast ( e . message , 'error' ) ; }
} ;
const typeInfo = APP _TYPES [ appType ] || APP _TYPES [ 'JAMA-Chat' ] ;
const siteUrl = window . location . origin ;
return (
< div >
{ /* Info box */ }
< div style = { { background : 'var(--surface-variant)' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , padding : '14px 16px' , marginBottom : 24 } } >
< p style = { { fontSize : 13 , fontWeight : 600 , marginBottom : 6 } } >
Registration { activeCode ? 'is' : 'required:' }
< / p >
< p style = { { fontSize : 13 , color : 'var(--text-secondary)' , lineHeight : 1.6 } } >
JAMA { activeCode ? 'is' : 'will be' } registered to : < br / >
< strong > Type : < / strong > { typeInfo . label } < br / >
< strong > URL : < / strong > { siteUrl }
< / p >
< / div >
{ /* Type */ }
< div style = { { marginBottom : 16 } } >
< div className = "settings-section-label" > Application Type < / div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 , marginTop : 6 } } >
< div style = { { padding : '7px 14px' , borderRadius : 'var(--radius)' , border : '1px solid var(--border)' , background : 'var(--surface-variant)' , fontSize : 14 , fontWeight : 600 , color : 'var(--primary)' } } >
{ typeInfo . label }
< / div >
< span style = { { fontSize : 12 , color : 'var(--text-tertiary)' } } > { typeInfo . desc } < / span >
< / div >
< / div >
{ /* Serial Number */ }
< div style = { { marginBottom : 16 } } >
< div className = "settings-section-label" > Serial Number < / div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginTop : 6 } } >
< input className = "input flex-1" value = { serialNumber } readOnly style = { { fontFamily : 'monospace' , letterSpacing : 1 } } / >
< button className = "btn btn-secondary btn-sm" onClick = { handleCopySerial } style = { { flexShrink : 0 } } >
{ copied ? '✓ Copied' : (
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < rect x = "9" y = "9" width = "13" height = "13" rx = "2" ry = "2" / > < path d = "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" / > < / svg >
) }
< / button >
< / div >
< / div >
{ /* Registration Code */ }
< div style = { { marginBottom : 20 } } >
< div className = "settings-section-label" > Registration Code < / div >
< div style = { { display : 'flex' , gap : 8 , marginTop : 6 } } >
< input className = "input flex-1" placeholder = "Enter registration code" value = { regCode }
onChange = { e => setRegCode ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleRegister ( ) } / >
< button className = "btn btn-primary btn-sm" onClick = { handleRegister } disabled = { regLoading } >
{ regLoading ? '…' : 'Register' }
< / button >
< / div >
< / div >
{ activeCode && (
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 } } >
< span style = { { fontSize : 13 , color : 'var(--success)' , display : 'flex' , alignItems : 'center' , gap : 5 } } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" > < polyline points = "20 6 9 17 4 12" / > < / svg >
Registered — { typeInfo . label }
< / span >
< button className = "btn btn-secondary btn-sm" onClick = { handleClear } > Clear < / button >
< / div >
) }
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 16 , lineHeight : 1.5 } } >
Registration codes unlock application features . Contact your JAMA provider for a code . < br / >
< strong > JAMA - Brand < / strong > — unlocks Branding . & nbsp ;
< strong > JAMA - Team < / strong > — unlocks Branding , Group Manager and Schedule Manager .
< / p >
< / div >
) ;
}
// ── Web Push Tab ──────────────────────────────────────────────────────────────
function WebPushTab ( ) {
const toast = useToast ( ) ;
const [ vapidPublic , setVapidPublic ] = useState ( '' ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ generating , setGenerating ] = useState ( false ) ;
const [ showRegenWarning , setShowRegenWarning ] = useState ( false ) ;
// Registration
const [ regCode , setRegCode ] = useState ( '' ) ;
const [ activeCode , setActiveCode ] = useState ( '' ) ;
const [ features , setFeatures ] = useState ( { branding : false , groupManager : false } ) ;
const [ regLoading , setRegLoading ] = useState ( false ) ;
useEffect ( ( ) => {
api . getSettings ( ) . then ( ( { settings } ) => {
setVapidPublic ( settings . vapid _public || '' ) ;
setActiveCode ( settings . registration _code || '' ) ;
setFeatures ( {
branding : settings . feature _branding === 'true' ,
groupManager : settings . feature _group _manager === 'true' ,
} ) ;
setLoading ( false ) ;
} ) . catch ( ( ) => setLoading ( false ) ) ;
} , [ ] ) ;
@@ -36,41 +233,81 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
toast ( 'VAPID keys generated. Push notifications are now active.' , 'success' ) ;
} catch ( e ) {
toast ( e . message || 'Failed to generate keys' , 'error' ) ;
} finally {
setGenerating ( false ) ;
}
} finally { setGenerating ( false ) ; }
} ;
const handleGenerateClick = ( ) => {
if ( vapidPublic ) setShowRegenWarning ( true ) ;
else doGenerate ( ) ;
} ;
if ( loading ) return < p style = { { fontSize : 13 , color : 'var(--text-secondary)' } } > Loading … < / p > ;
const handleRegister = async ( ) => {
setRegLoading ( true ) ;
try {
const { features : f } = await api . registerCode ( regCode . trim ( ) ) ;
setFeatures ( f ) ;
setActiveCode ( regCode . trim ( ) ) ;
setRegCode ( '' ) ;
toast ( regCode . trim ( ) ? 'Registration code applied.' : 'Registration cleared.' , 'success' ) ;
window . dispatchEvent ( new Event ( 'jama:settings-changed' ) ) ;
onFeaturesChanged && onFeaturesChanged ( f ) ;
} catch ( e ) {
toast ( e . message || 'Invalid code' , 'error' ) ;
} finally {
setRegLoading ( false ) ;
}
} ;
return (
< div >
< div className = "settings-section-label" style = { { marginBottom : 12 } } > Web Push Notifications ( VAPID ) < / div >
const featureList = [
features . branding && 'Branding' ,
features . groupManager && 'Group Manager' ,
{ vapidPublic ? (
< div style = { { marginBottom : 16 } } >
< div style = { { background : 'var(--surface-variant)' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , padding : '10px 12px' , marginBottom : 10 } } >
< div style = { { fontSize : 11 , color : 'var(--text-tertiary)' , marginBottom : 4 , textTransform : 'uppercase' , letterSpacing : '0.5px' } } > Public Key < / div >
< code style = { { fontSize : 11 , color : 'var(--text-primary)' , wordBreak : 'break-all' , lineHeight : 1.5 , display : 'block' } } > { vapidPublic } < / code >
< / div >
< span style = { { fontSize : 13 , color : 'var(--success)' , display : 'flex' , alignItems : 'center' , gap : 5 } } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" > < polyline points = "20 6 9 17 4 12" / > < / svg >
Push notifications active
< / span >
< / div >
) : (
< p style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginBottom : 12 } } >
No VAPID keys found . Generate keys to enable Web Push notifications .
< / p >
) }
{ showRegenWarning && (
< div style = { { background : '#fce8e6' , border : '1px solid #f5c6c2' , borderRadius : 'var(--radius)' , padding : '14px 16px' , marginBottom : 16 } } >
< p style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--error)' , marginBottom : 8 } } > ⚠ ️ Regenerate VAPID keys ? < / p >
< p style = { { fontSize : 13 , color : '#5c2c28' , marginBottom : 12 , lineHeight : 1.5 } } >
Generating new keys will < strong > invalidate all existing push subscriptions < / strong > . Users will need to re - enable notifications .
< / p >
< div style = { { display : 'flex' , gap : 8 } } >
< button className = "btn btn-sm" style = { { background : 'var(--error)' , color : 'white' } } onClick = { doGenerate } disabled = { generating } > { generating ? 'Generating…' : 'Yes, regenerate keys' } < / button >
< button className = "btn btn-secondary btn-sm" onClick = { ( ) => setShowRegenWarning ( false ) } > Cancel < / button >
< / div >
< / div >
) }
{ ! showRegenWarning && (
< button className = "btn btn-primary btn-sm" onClick = { ( ) => vapidPublic ? setShowRegenWarning ( true ) : doGenerate ( ) } disabled = { generating } >
{ generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys' }
< / button >
) }
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 12 , lineHeight : 1.5 } } >
Requires HTTPS . On iOS , the app must be installed to the home screen first .
< / p >
< / div >
) ;
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal ( { onClose , onFeaturesChanged } ) {
const [ tab , setTab ] = useState ( 'registration' ) ;
const [ appType , setAppType ] = useState ( 'JAMA-Chat' ) ;
useEffect ( ( ) => {
api . getSettings ( ) . then ( ( { settings } ) => {
setAppType ( settings . app _type || 'JAMA-Chat' ) ;
} ) . catch ( ( ) => { } ) ;
const handler = ( ) => api . getSettings ( ) . then ( ( { settings } ) => setAppType ( settings . app _type || 'JAMA-Chat' ) ) . catch ( ( ) => { } ) ;
window . addEventListener ( 'jama:settings-changed' , handler ) ;
return ( ) => window . removeEventListener ( 'jama:settings-changed' , handler ) ;
} , [ ] ) ;
const isTeam = appType === 'JAMA-Team' ;
const tabs = [
isTeam && { id : 'team' , label : 'Team Management' } ,
{ id : 'registration' , label : 'Registration' } ,
{ id : 'webpush' , label : 'Web Push' } ,
] . filter ( Boolean ) ;
return (
< div className = "modal-overlay" onClick = { e => e . target === e . currentTarget && onClose ( ) } >
< div className = "modal" style = { { maxWidth : 48 0 } } >
< div className = "modal" style = { { maxWidth : 52 0 } } >
< div className = "flex items-center justify-between" style = { { marginBottom : 20 } } >
< h2 className = "modal-title" style = { { margin : 0 } } > Settings < / h2 >
< button className = "btn-icon" onClick = { onClose } >
@@ -78,82 +315,18 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
< / button >
< / div >
{ /* Registration Code */ }
< div style = { { marginBottom : 28 } } >
< div className = "settings-section-label" > Feature Registration < / div >
{ activeCode && featureList . length > 0 && (
< div style = { { marginBottom : 10 , display : 'flex' , alignItems : 'center' , gap : 6 , fontSize : 13 , color : 'var(--success)' } } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" > < polyline points = "20 6 9 17 4 12" / > < / svg >
Active — unlocks : { featureList . join ( ', ' ) }
< / div >
) }
< div style = { { display : 'flex' , gap : 8 , marginBottom : 8 } } >
< input
className = "input flex-1"
placeholder = "Enter registration code"
value = { regCode }
onChange = { e => setRegCode ( e . target . value ) }
onKeyDown = { e => e . key === 'Enter' && handleRegister ( ) }
/ >
< button className = "btn btn-primary btn-sm" onClick = { handleRegister } disabled = { regLoading } >
{ regLoading ? '…' : 'Apply' }
{ /* Tab buttons */ }
< div className = "flex gap-2" style= { { marginBottom : 24 } } >
{ tabs . map ( t => (
< button key = { t . id } className = { ` btn btn-sm ${ tab === t . id ? 'btn-primary' : 'btn-secondary' } ` } onClick = { ( ) => setTab ( t . id ) } >
{ t . label }
< / button >
< / div >
{ activeCode && (
< button className = "btn btn-secondary btn-sm" onClick = { ( ) => { setRegCode ( '' ) ; api . registerCode ( '' ) . then ( ( ) => { setFeatures ( { branding : false , groupManager : false } ) ; setActiveCode ( '' ) ; window . dispatchEvent ( new Event ( 'jama:settings-changed' ) ) ; onFeaturesChanged && onFeaturesChanged ( { branding : false , groupManager : false } ) ; } ) ; } } >
Clear Registration
< / button >
) }
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 8 } } >
A registration code unlocks Branding and Group Manager features . Contact your jama provider for a code .
< / p >
) ) }
< / div >
< div style = { { borderTop : '1px solid var(--border)' , paddingTop : 20 } } >
< div className = "settings-section-label" > Web Push Notifications ( VAPID ) < / div >
{ loading ? (
< p style = { { fontSize : 13 , color : 'var(--text-secondary)' } } > Loading … < / p >
) : (
< >
{ vapidPublic ? (
< div style = { { marginBottom : 16 } } >
< div style = { { background : 'var(--surface-variant)' , border : '1px solid var(--border)' , borderRadius : 'var(--radius)' , padding : '10px 12px' , marginBottom : 10 } } >
< div style = { { fontSize : 11 , color : 'var(--text-tertiary)' , marginBottom : 4 , textTransform : 'uppercase' , letterSpacing : '0.5px' } } > Public Key < / div >
< code style = { { fontSize : 11 , color : 'var(--text-primary)' , wordBreak : 'break-all' , lineHeight : 1.5 , display : 'block' } } > { vapidPublic } < / code >
< / div >
< span style = { { fontSize : 13 , color : 'var(--success)' , display : 'flex' , alignItems : 'center' , gap : 5 } } >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" > < polyline points = "20 6 9 17 4 12" / > < / svg >
Push notifications active
< / span >
< / div >
) : (
< p style = { { fontSize : 13 , color : 'var(--text-secondary)' , marginBottom : 12 } } >
No VAPID keys found . Generate keys to enable Web Push notifications .
< / p >
) }
{ showRegenWarning && (
< div style = { { background : '#fce8e6' , border : '1px solid #f5c6c2' , borderRadius : 'var(--radius)' , padding : '14px 16px' , marginBottom : 16 } } >
< p style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--error)' , marginBottom : 8 } } > ⚠ ️ Regenerate VAPID keys ? < / p >
< p style = { { fontSize : 13 , color : '#5c2c28' , marginBottom : 12 , lineHeight : 1.5 } } >
Generating new keys will < strong > invalidate all existing push subscriptions < / strong > . Users will need to re - enable notifications .
< / p >
< div style = { { display : 'flex' , gap : 8 } } >
< button className = "btn btn-sm" style = { { background : 'var(--error)' , color : 'white' } } onClick = { doGenerate } disabled = { generating } > { generating ? 'Generating…' : 'Yes, regenerate keys' } < / button >
< button className = "btn btn-secondary btn-sm" onClick = { ( ) => setShowRegenWarning ( false ) } > Cancel < / button >
< / div >
< / div >
) }
{ ! showRegenWarning && (
< button className = "btn btn-primary btn-sm" onClick = { handleGenerateClick } disabled = { generating } >
{ generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys' }
< / button >
) }
< p style = { { fontSize : 12 , color : 'var(--text-tertiary)' , marginTop : 12 , lineHeight : 1.5 } } >
Requires HTTPS . On iOS , the app must be installed to the home screen first .
< / p >
< / >
) }
< / div >
{ tab === 'team' && < TeamManagementTab features = { { appType } } / > }
{ tab === 'registration' && < RegistrationTab onFeaturesChanged = { onFeaturesChanged } / > }
{ tab === 'webpush' && < WebPushTab / > }
< / div >
< / div >
) ;