v0.12.47 Add Child alias update

This commit is contained in:
2026-03-31 13:51:47 -04:00
parent 350bb25ecd
commit 9c263e7e8d
7 changed files with 288 additions and 228 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
"version": "0.12.46",
"version": "0.12.47",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.46}"
VERSION="${1:-0.12.47}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.46",
"version": "0.12.47",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
export default function AddChildAliasModal({ onClose }) {
const toast = useToast();
const [aliases, setAliases] = useState([]);
const [editingAlias, setEditingAlias] = useState(null); // null = new entry
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
const [avatarFile, setAvatarFile] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
}, []);
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
const resetForm = () => {
setEditingAlias(null);
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
setAvatarFile(null);
};
const handleSelectAlias = (a) => {
if (editingAlias?.id === a.id) { resetForm(); return; }
setEditingAlias(a);
setForm({
firstName: a.first_name || '',
lastName: a.last_name || '',
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
phone: a.phone || '',
email: a.email || '',
});
setAvatarFile(null);
};
const handleSave = async () => {
if (!form.firstName.trim() || !form.lastName.trim())
return toast('First and last name required', 'error');
setSaving(true);
try {
if (editingAlias) {
await api.updateAlias(editingAlias.id, {
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
dateOfBirth: form.dob || null,
phone: form.phone || null,
email: form.email || null,
});
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
toast('Child alias updated', 'success');
} else {
const { alias } = await api.createAlias({
firstName: form.firstName.trim(),
lastName: form.lastName.trim(),
dateOfBirth: form.dob || null,
phone: form.phone || null,
email: form.email || null,
});
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
toast('Child alias added', 'success');
}
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
resetForm();
} catch (e) {
toast(e.message, 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async (e, aliasId) => {
e.stopPropagation();
try {
await api.deleteAlias(aliasId);
setAliases(prev => prev.filter(a => a.id !== aliasId));
if (editingAlias?.id === aliasId) resetForm();
toast('Child alias removed', 'success');
} catch (err) { toast(err.message, 'error'); }
};
const lbl = (text, required) => (
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
</label>
);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Add Child Alias</h2>
<button className="btn-icon" onClick={onClose} aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Existing aliases list */}
{aliases.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
Your Children click to edit
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{aliases.map((a, i) => (
<div
key={a.id}
onClick={() => handleSelectAlias(a)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 12px', cursor: 'pointer',
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
}}
>
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
{a.first_name} {a.last_name}
</span>
{a.date_of_birth && (
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
{a.date_of_birth.slice(0, 10)}
</span>
)}
<button
onClick={e => handleDelete(e, a.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
aria-label="Remove"
>×</button>
</div>
))}
</div>
</div>
)}
{/* Form section label */}
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
{editingAlias
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
: 'New Child Alias'}
</div>
{/* Form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
{lbl('First Name', true)}
<input className="input" value={form.firstName} onChange={set('firstName')}
autoComplete="off" autoCapitalize="words" />
</div>
<div>
{lbl('Last Name', true)}
<input className="input" value={form.lastName} onChange={set('lastName')}
autoComplete="off" autoCapitalize="words" />
</div>
<div>
{lbl('Date of Birth')}
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
autoComplete="off" />
</div>
<div>
{lbl('Phone')}
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
autoComplete="off" />
</div>
</div>
<div>
{lbl('Email (optional)')}
<input className="input" type="email" value={form.email} onChange={set('email')}
autoComplete="off" />
</div>
<div>
{lbl('Avatar (optional)')}
<input type="file" accept="image/*"
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
{editingAlias && (
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
)}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -13,13 +13,14 @@ const NAV_ICON = {
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
};
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, onAddChild, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
const { user } = useAuth();
const drawerRef = useRef(null);
const isAdmin = user?.role === 'admin';
const userGroupIds = features.userGroupMemberships || [];
const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
const hasUserGroups = userGroupIds.length > 0;
const showAddChild = features.loginType === 'guardian_only' && features.inGuardiansGroup;
useEffect(() => {
if (!open) return;
@@ -80,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
)}
{/* Tools section */}
{canAccessTools && (
{(canAccessTools || showAddChild) && (
<>
<div className="nav-drawer-section-label admin">Tools</div>
{item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
{canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
{canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
{showAddChild && item(
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>,
'Add Child Aliase',
onAddChild
)}
</>
)}
</div>

View File

@@ -23,7 +23,7 @@ export default function ProfileModal({ onClose }) {
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' | 'add-child'
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
const [pushTesting, setPushTesting] = useState(false);
const [pushResult, setPushResult] = useState(null);
const [notifPermission, setNotifPermission] = useState(
@@ -36,20 +36,8 @@ export default function ProfileModal({ onClose }) {
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
// Minor age protection state
const [loginType, setLoginType] = useState('all_ages');
const [guardiansGroupId,setGuardiansGroupId] = useState(null);
const [showAddChild, setShowAddChild] = useState(false);
const [aliases, setAliases] = useState([]);
// Add Child form state
const [childList, setChildList] = useState([]); // pending aliases to add
const [childForm, setChildForm] = useState({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
const [childFormAvatar, setChildFormAvatar] = useState(null);
const [childSaving, setChildSaving] = useState(false);
// Mixed Age: minor user search
const [minorSearch, setMinorSearch] = useState('');
const [minorResults, setMinorResults] = useState([]);
const [selectedMinor, setSelectedMinor] = useState(null);
// Minor age protection — DOB/phone display only
const [loginType, setLoginType] = useState('all_ages');
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
const [fontScale, setFontScale] = useState(
@@ -62,32 +50,13 @@ export default function ProfileModal({ onClose }) {
return () => window.removeEventListener('resize', onResize);
}, []);
// Load login type + check if user is in guardians group
// Load login type for DOB/phone field visibility
useEffect(() => {
Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { userGroups }]) => {
const lt = s.feature_login_type || 'all_ages';
const gid = parseInt(s.feature_guardians_group_id);
setLoginType(lt);
setGuardiansGroupId(gid || null);
if (lt === 'guardian_only') {
// In guardian_only mode all authenticated users are guardians — always show Add Child
setShowAddChild(true);
} else if (lt === 'mixed_age' && gid) {
const inGroup = (userGroups || []).some(g => g.id === gid);
setShowAddChild(inGroup);
}
api.getSettings().then(({ settings: s }) => {
setLoginType(s.feature_login_type || 'all_ages');
}).catch(() => {});
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
}, []);
useEffect(() => {
if (loginType === 'mixed_age' && minorSearch.length >= 1) {
api.searchMinorUsers(minorSearch).then(({ users }) => setMinorResults(users || [])).catch(() => {});
} else {
setMinorResults([]);
}
}, [minorSearch, loginType]);
const applyFontScale = (val) => {
setFontScale(val);
document.documentElement.style.setProperty('--font-scale', val);
@@ -109,46 +78,6 @@ export default function ProfileModal({ onClose }) {
}
};
const handleSaveChildren = async () => {
if (childList.length === 0) return;
setChildSaving(true);
try {
if (loginType === 'mixed_age') {
// Link each selected minor
for (const minor of childList) {
await api.linkMinor(minor.id);
}
toast('Guardian link request sent — awaiting manager approval', 'success');
} else {
// Create aliases
for (const child of childList) {
const { alias } = await api.createAlias({ firstName: child.firstName, lastName: child.lastName, email: child.email, dateOfBirth: child.dob, phone: child.phone });
if (child.avatarFile) {
await api.uploadAliasAvatar(alias.id, child.avatarFile);
}
}
toast('Children saved', 'success');
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
}
setChildList([]);
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
setSelectedMinor(null);
} catch (e) {
toast(e.message, 'error');
} finally {
setChildSaving(false);
}
};
const handleRemoveAlias = async (aliasId) => {
try {
await api.deleteAlias(aliasId);
setAliases(prev => prev.filter(a => a.id !== aliasId));
toast('Child removed', 'success');
} catch (e) { toast(e.message, 'error'); }
};
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -219,7 +148,6 @@ export default function ProfileModal({ onClose }) {
<option value="password">Change Password</option>
<option value="notifications">Notifications</option>
<option value="appearance">Appearance</option>
{showAddChild && <option value="add-child">Add Child</option>}
</select>
</div>
@@ -447,122 +375,6 @@ export default function ProfileModal({ onClose }) {
</div>
)}
{tab === 'add-child' && (
<div className="flex-col gap-3">
{/* Existing saved aliases */}
{aliases.length > 0 && (
<div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>Saved Children</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 12 }}>
{aliases.map((a, i) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{a.first_name} {a.last_name}</span>
{a.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{a.date_of_birth.slice(0,10)}</span>}
<button onClick={() => handleRemoveAlias(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16, lineHeight: 1 }}>×</button>
</div>
))}
</div>
</div>
)}
{loginType === 'guardian_only' ? (
<>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add a Child</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10 }}>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>First Name *</label>
<input className="input" value={childForm.firstName} onChange={e => setChildForm(p=>({...p,firstName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Last Name *</label>
<input className="input" value={childForm.lastName} onChange={e => setChildForm(p=>({...p,lastName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
<input className="input" placeholder="YYYY-MM-DD" value={childForm.dob} onChange={e => setChildForm(p=>({...p,dob:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Phone</label>
<input className="input" type="tel" value={childForm.phone} onChange={e => setChildForm(p=>({...p,phone:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Email (optional)</label>
<input className="input" type="email" value={childForm.email} onChange={e => setChildForm(p=>({...p,email:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Avatar (optional)</label>
<input type="file" accept="image/*" onChange={e => setChildForm(p=>({...p,avatarFile:e.target.files?.[0]||null}))} />
</div>
</div>
<button className="btn btn-secondary btn-sm" style={{ alignSelf: 'flex-start' }}
onClick={() => {
if (!childForm.firstName.trim() || !childForm.lastName.trim()) return toast('First and last name required','error');
setChildList(prev => [...prev, { ...childForm }]);
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'', avatarFile:null });
}}>
+ Add
</button>
{childList.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{childList.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{c.firstName} {c.lastName}</span>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>Pending save</span>
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
</div>
))}
</div>
)}
</>
) : loginType === 'mixed_age' ? (
<>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Link a Child Account</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', margin: 0 }}>Search for a minor user account to link to your guardian profile. The link requires manager approval.</p>
<input className="input" placeholder="Search minor users..." value={minorSearch} onChange={e => setMinorSearch(e.target.value)} autoComplete="off" />
{minorResults.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 160, overflowY: 'auto' }}>
{minorResults.map(u => (
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}
onClick={() => { setSelectedMinor(u); setMinorSearch(''); setMinorResults([]); }}>
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
{u.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{u.date_of_birth.slice(0,10)}</span>}
</div>
))}
</div>
)}
{selectedMinor && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px' }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{selectedMinor.first_name} {selectedMinor.last_name}</div>
{selectedMinor.date_of_birth && <div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{selectedMinor.date_of_birth.slice(0,10)}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-secondary btn-sm"
onClick={() => { setChildList(prev => [...prev, selectedMinor]); setSelectedMinor(null); }}>
+ Add to list
</button>
<button className="btn btn-sm" style={{ background: 'none', border: '1px solid var(--border)' }} onClick={() => setSelectedMinor(null)}>Clear</button>
</div>
</div>
)}
{childList.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{childList.map((c, i) => (
<div key={c.id || i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{c.first_name || c.firstName} {c.last_name || c.lastName}</span>
<span style={{ fontSize: 12, color: 'var(--warning)', fontStyle: 'italic' }}>Pending approval</span>
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
</div>
))}
</div>
)}
</>
) : null}
<button className="btn btn-primary" onClick={handleSaveChildren} disabled={childSaving || childList.length === 0}>
{childSaving ? 'Saving' : 'Save'}
</button>
</div>
)}
{tab === 'appearance' && (
<div className="flex-col gap-3">
<div className="flex-col gap-2">

View File

@@ -16,6 +16,7 @@ import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx';
import NavDrawer from '../components/NavDrawer.jsx';
import AddChildAliasModal from '../components/AddChildAliasModal.jsx';
import SchedulePage from '../components/SchedulePage.jsx';
import MobileGroupManager from '../components/MobileGroupManager.jsx';
import './Chat.css';
@@ -48,6 +49,9 @@ export default function Chat() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true });
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [addChildPending, setAddChildPending] = useState(false); // defer add-child popup until help closes
const addChildCheckedRef = useRef(false); // only auto-check aliases once per session
const modalRef = useRef(null); // always reflects current modal value in async callbacks
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
@@ -80,28 +84,31 @@ export default function Chat() {
// Keep groupsRef in sync so visibility/reconnect handlers can read current groups
useEffect(() => { groupsRef.current = groups; }, [groups]);
// Load feature flags + current user's group memberships on mount
// Load feature flags + current user's group memberships on mount (combined for consistent inGuardiansGroup)
const loadFeatures = useCallback(() => {
api.getSettings().then(({ settings }) => {
setFeatures(prev => ({
...prev,
branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true',
scheduleManager: settings.feature_schedule_manager === 'true',
appType: settings.app_type || 'RosterChirp-Chat',
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
isHostDomain: settings.is_host_domain === 'true',
msgPublic: settings.feature_msg_public !== 'false',
msgGroup: settings.feature_msg_group !== 'false',
msgPrivateGroup: settings.feature_msg_private_group !== 'false',
msgU2U: settings.feature_msg_u2u !== 'false',
loginType: settings.feature_login_type || 'all_ages',
playersGroupId: settings.feature_players_group_id ? parseInt(settings.feature_players_group_id) : null,
}));
}).catch(() => {});
api.getMyUserGroups().then(({ userGroups }) => {
setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) }));
}).catch(() => {});
Promise.all([api.getSettings(), api.getMyUserGroups()])
.then(([{ settings: s }, { userGroups }]) => {
const memberships = (userGroups || []).map(g => g.id);
const guardiansGroupId = s.feature_guardians_group_id ? parseInt(s.feature_guardians_group_id) : null;
setFeatures(prev => ({
...prev,
branding: s.feature_branding === 'true',
groupManager: s.feature_group_manager === 'true',
scheduleManager: s.feature_schedule_manager === 'true',
appType: s.app_type || 'RosterChirp-Chat',
teamToolManagers: JSON.parse(s.team_tool_managers || s.team_group_managers || '[]'),
isHostDomain: s.is_host_domain === 'true',
msgPublic: s.feature_msg_public !== 'false',
msgGroup: s.feature_msg_group !== 'false',
msgPrivateGroup: s.feature_msg_private_group !== 'false',
msgU2U: s.feature_msg_u2u !== 'false',
loginType: s.feature_login_type || 'all_ages',
playersGroupId: s.feature_players_group_id ? parseInt(s.feature_players_group_id) : null,
guardiansGroupId,
userGroupMemberships: memberships,
inGuardiansGroup: guardiansGroupId ? memberships.includes(guardiansGroupId) : false,
}));
}).catch(() => {});
}, []);
useEffect(() => {
@@ -110,6 +117,35 @@ export default function Chat() {
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]);
// Keep modalRef in sync so async callbacks can read current modal without stale closure
useEffect(() => { modalRef.current = modal; }, [modal]);
// Auto-popup Add Child Alias modal when guardian_only user has no aliases yet
useEffect(() => {
if (addChildCheckedRef.current) return;
if (features.loginType !== 'guardian_only' || !features.inGuardiansGroup) return;
addChildCheckedRef.current = true;
api.getAliases().then(({ aliases }) => {
if (!(aliases || []).length) {
if (modalRef.current === 'help') {
setAddChildPending(true); // defer until help closes
} else if (!modalRef.current) {
setModal('addchild');
}
}
}).catch(() => {});
}, [features.loginType, features.inGuardiansGroup]);
// Close help — open deferred add-child popup if pending
const handleHelpClose = useCallback(() => {
if (addChildPending) {
setAddChildPending(false);
setModal('addchild');
} else {
setModal(null);
}
}, [addChildPending]);
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
@@ -601,12 +637,14 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
@@ -630,12 +668,14 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
@@ -689,12 +729,14 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
@@ -721,6 +763,7 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features}
currentPage={page}
isMobile={isMobile}
@@ -728,7 +771,8 @@ export default function Chat() {
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div>
@@ -760,6 +804,7 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features}
currentPage={page}
isMobile={isMobile}
@@ -774,7 +819,8 @@ export default function Chat() {
</div>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
</div>
);
@@ -842,7 +888,8 @@ export default function Chat() {
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
</div>
);
}