v0.12.43 minor protection added

This commit is contained in:
2026-03-30 16:02:09 -04:00
parent e8e941c436
commit fe836ae69f
18 changed files with 1132 additions and 105 deletions

View File

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

View File

@@ -21,6 +21,9 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
const [users, setUsers] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
// Mixed Age: guardian confirmation modal
const [guardianConfirm, setGuardianConfirm] = useState(null); // { group, guardianName }
const loginType = features.loginType || 'all_ages';
// True when exactly 1 user selected on private tab AND U2U messages are enabled
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
@@ -69,13 +72,18 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
};
}
const { group, duplicate } = await api.createGroup(payload);
const { group, duplicate, guardianAdded, guardianName } = await api.createGroup(payload);
if (duplicate) {
toast('A group with these members already exists — opening it now.', 'info');
onCreated(group);
} else {
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
if (guardianAdded && guardianName) {
setGuardianConfirm({ group, guardianName });
} else {
onCreated(group);
}
}
onCreated(group);
} catch (e) {
toast(e.message, 'error');
} finally {
@@ -172,6 +180,20 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
</button>
</div>
</div>
{guardianConfirm && (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: 360 }}>
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Added</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
<strong>{guardianConfirm.guardianName}</strong> has been added to this conversation as the guardian of this minor.
</p>
<div className="flex justify-end">
<button className="btn btn-primary" onClick={() => { setGuardianConfirm(null); onCreated(guardianConfirm.group); }}>OK</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -17,11 +17,13 @@ export default function ProfileModal({ onClose }) {
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
const [dob, setDob] = useState(user?.date_of_birth ? user.date_of_birth.slice(0, 10) : '');
const [phone, setPhone] = useState(user?.phone || '');
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' | 'add-child'
const [pushTesting, setPushTesting] = useState(false);
const [pushResult, setPushResult] = useState(null);
const [notifPermission, setNotifPermission] = useState(
@@ -32,6 +34,21 @@ 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);
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
const [fontScale, setFontScale] = useState(
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
@@ -43,6 +60,29 @@ export default function ProfileModal({ onClose }) {
return () => window.removeEventListener('resize', onResize);
}, []);
// Load login type + check if user is in guardians group
useEffect(() => {
Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { groups }]) => {
const lt = s.feature_login_type || 'all_ages';
const gid = parseInt(s.feature_guardians_group_id);
setLoginType(lt);
setGuardiansGroupId(gid || null);
if (lt !== 'all_ages' && gid) {
const inGroup = (groups || []).some(g => g.id === gid);
setShowAddChild(inGroup);
}
}).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);
@@ -53,7 +93,7 @@ export default function ProfileModal({ onClose }) {
if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob || null, phone: phone || null });
updateUser(updated);
setSavedDisplayName(displayName);
toast('Profile updated', 'success');
@@ -64,6 +104,46 @@ 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;
@@ -126,27 +206,17 @@ export default function ProfileModal({ onClose }) {
</div>
</div>
{/* Tabs — select on mobile, buttons on desktop */}
{isMobile ? (
<select
className="input"
value={tab}
onChange={e => { setTab(e.target.value); setPushResult(null); }}
style={{ marginBottom: 20 }}
>
{/* Tab navigation — unified select list on all screen sizes */}
<div style={{ marginBottom: 20 }}>
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<select className="input" value={tab} onChange={e => { setTab(e.target.value); setPushResult(null); }}>
<option value="profile">Profile</option>
<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 className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button>
<button className={`btn btn-sm ${tab === 'appearance' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('appearance')}>Appearance</button>
</div>
)}
</div>
{tab === 'profile' && (
<div className="flex-col gap-3">
@@ -206,6 +276,19 @@ export default function ProfileModal({ onClose }) {
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
Allow others to send me direct messages
</label>
{/* Date of Birth + Phone — visible in Guardian Only / Mixed Age modes */}
{loginType !== 'all_ages' && (
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
<input className="input" type="text" placeholder="YYYY-MM-DD" value={dob} onChange={e => setDob(e.target.value)} autoComplete="off" />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Phone</label>
<input className="input" type="tel" placeholder="+1 555 000 0000" value={phone} onChange={e => setPhone(e.target.value)} autoComplete="tel" />
</div>
</div>
)}
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</button>
@@ -355,6 +438,122 @@ 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

@@ -789,6 +789,11 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>')
const myAliases = event.my_aliases || [];
const showResponderSelect = !!(event.has_players_group && myAliases.length > 0);
const [responder, setResponder] = useState('all');
// Sync when parent reloads event after availability change
useEffect(()=>{
setMyResp(event.my_response);
@@ -802,6 +807,37 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const noteChanged = noteInput.trim() !== myNote.trim();
const handleResp=async resp=>{
// Guardian Only multi-responder logic
if (showResponderSelect) {
const note = noteInput.trim() || null;
// Build list of responders for this action
const targets = responder === 'all'
? [
...(event.in_guardians_group ? [{ type:'self' }] : []),
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
]
: responder === 'self'
? [{ type:'self' }]
: [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }];
try {
for (const t of targets) {
const prevResp = t.type === 'self'
? myResp
: (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null);
if (prevResp === resp) {
await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined);
} else {
await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined);
}
}
if (targets.some(t => t.type === 'self')) setMyResp(prev => prev === resp ? null : resp);
onAvailabilityChange?.(resp);
} catch(e) { toast(e.message,'error'); }
return;
}
// Normal (non-Guardian-Only) path
const prev=myResp;
const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update
@@ -826,6 +862,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const handleDownloadAvailability = () => {
// Format as "Lastname, Firstname" using first_name/last_name fields when available
const fmtName = u => {
// Alias entries have first_name/last_name directly
const last = (u.last_name || '').trim();
const first = (u.first_name || '').trim();
if (last && first) return `${last}, ${first}`;
@@ -937,6 +974,18 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</button>
))}
</div>
{/* Guardian Only: responder select — shown when event targets the players group and user has aliases */}
{showResponderSelect && (
<div style={{marginBottom:10}}>
<label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
<select value={responder} onChange={e=>setResponder(e.target.value)}
style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
<option value="all">All</option>
{event.in_guardians_group && <option value="self">{/* guardian's own name shown as self */}My own response</option>}
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
</select>
</div>
)}
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
<input
type="text"
@@ -965,16 +1014,21 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{avail.length>0&&(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
{avail.map(r=>{
const rowKey = r.is_alias ? `alias:${r.alias_id}` : `user:${r.user_id}`;
const displayName = r.is_alias
? `${r.first_name} ${r.last_name}`
: (r.display_name || r.name);
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(r.user_id);
const expanded=expandedNotes.has(rowKey);
return(
<div key={r.user_id} style={{borderBottom:'1px solid var(--border)'}}>
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(r.user_id):undefined}
onClick={hasNote?()=>toggleNote(rowKey):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{r.display_name||r.name}</span>
<span style={{flex:1}}>{displayName}</span>
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}

View File

@@ -158,6 +158,121 @@ function TeamManagementTab() {
);
}
// ── Login Type Tab ────────────────────────────────────────────────────────────
const LOGIN_TYPE_OPTIONS = [
{
id: 'all_ages',
label: 'All Ages',
desc: 'No age restrictions. All users interact normally. Default behaviour.',
},
{
id: 'guardian_only',
label: 'Guardian Only',
desc: "Parents are required to add their child's details in their profile. They respond on behalf of the child for events with availability tracking for the players group.",
},
{
id: 'mixed_age',
label: 'Mixed Age',
desc: "Parents, or user managers, add the minor's user account to their guardian profile. Minor aged users cannot login until a manager approves the guardian link.",
},
];
function LoginTypeTab() {
const toast = useToast();
const [loginType, setLoginType] = useState('all_ages');
const [playersGroupId, setPlayersGroupId] = useState('');
const [guardiansGroupId,setGuardiansGroupId] = useState('');
const [userGroups, setUserGroups] = useState([]);
const [canChange, setCanChange] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([api.getSettings(), api.getUserGroups()]).then(([{ settings: s }, { groups }]) => {
setLoginType(s.feature_login_type || 'all_ages');
setPlayersGroupId(s.feature_players_group_id || '');
setGuardiansGroupId(s.feature_guardians_group_id || '');
setUserGroups([...(groups || [])].sort((a, b) => a.name.localeCompare(b.name)));
}).catch(() => {});
// Determine if the user table is empty enough to allow changes
api.getUsers().then(({ users }) => {
const nonAdmins = (users || []).filter(u => u.role !== 'admin');
setCanChange(nonAdmins.length === 0);
}).catch(() => {});
}, []);
const handleSave = async () => {
setSaving(true);
try {
await api.updateLoginType({
loginType,
playersGroupId: playersGroupId ? parseInt(playersGroupId) : null,
guardiansGroupId: guardiansGroupId ? parseInt(guardiansGroupId) : null,
});
toast('Login Type settings saved', 'success');
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const needsGroups = loginType !== 'all_ages';
return (
<div>
<div className="settings-section-label">Login Type</div>
{/* Warning */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px', marginBottom: 16 }}>
<span style={{ fontSize: 16, lineHeight: 1 }}></span>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0, lineHeight: 1.5 }}>
This setting can only be set or changed when the user table is empty (no non-admin users exist).
</p>
</div>
{/* Options */}
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
{LOGIN_TYPE_OPTIONS.map((opt, i) => (
<label key={opt.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px', borderBottom: i < LOGIN_TYPE_OPTIONS.length - 1 ? '1px solid var(--border)' : 'none', cursor: canChange ? 'pointer' : 'not-allowed', opacity: canChange ? 1 : 0.6 }}>
<input type="radio" name="loginType" value={opt.id} checked={loginType === opt.id} disabled={!canChange}
onChange={() => setLoginType(opt.id)} style={{ marginTop: 3, accentColor: 'var(--primary)' }} />
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{opt.label}</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>{opt.desc}</div>
</div>
</label>
))}
</div>
{/* Group selectors — only shown for Guardian Only / Mixed Age */}
{needsGroups && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Players Group</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>The user group that children / aliases are added to.</p>
<select className="input" value={playersGroupId} disabled={!canChange}
onChange={e => setPlayersGroupId(e.target.value)}>
<option value=""> Select group </option>
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Guardians Group</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Members of this group see the "Add Child" option in their profile.</p>
<select className="input" value={guardiansGroupId} disabled={!canChange}
onChange={e => setGuardiansGroupId(e.target.value)}>
<option value=""> Select group </option>
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
</div>
)}
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !canChange}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
);
}
// ── Registration Tab ──────────────────────────────────────────────────────────
function RegistrationTab({ onFeaturesChanged }) {
const toast = useToast();
@@ -295,12 +410,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
const isTeam = appType === 'RosterChirp-Team';
const tabs = [
{ id: 'messages', label: 'Messages' },
isTeam && { id: 'team', label: 'Tools' },
{ id: 'registration', label: 'Registration' },
].filter(Boolean);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 520 }}>
@@ -311,17 +420,20 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
</button>
</div>
{/* 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>
))}
{/* Select navigation */}
<div style={{ marginBottom: 24 }}>
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<select className="input" value={tab} onChange={e => setTab(e.target.value)}>
<option value="messages">Messages</option>
{isTeam && <option value="team">Tools</option>}
<option value="login-type">Login Type</option>
<option value="registration">Registration</option>
</select>
</div>
{tab === 'messages' && <MessagesTab />}
{tab === 'team' && <TeamManagementTab />}
{tab === 'login-type' && <LoginTypeTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
</div>
</div>

View File

@@ -127,6 +127,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const msgPublic = features.msgPublic ?? true;
const msgU2U = features.msgU2U ?? true;
const msgPrivateGroup = features.msgPrivateGroup ?? true;
const loginType = features.loginType || 'all_ages';
const playersGroupId = features.playersGroupId ?? null;
const allGroups = [
...(groups.publicGroups || []),
@@ -143,6 +145,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
if (g.is_managed) return false;
if (g.is_direct && !msgU2U) return false;
if (!g.is_direct && !msgPrivateGroup) return false;
// Guardian Only: hide the managed DM channel for the designated players group
if (loginType === 'guardian_only' && g.is_managed && playersGroupId && g.source_user_group_id === playersGroupId) return false;
return true;
})].sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;

View File

@@ -95,6 +95,8 @@ export default function Chat() {
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 }) => {

View File

@@ -89,10 +89,11 @@ function UserRow({ u, onUpdated, onEdit }) {
<Avatar user={u} size="sm" />
<div style={{ flex:1, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
<span style={{ fontWeight:600, fontSize:14 }}>{u.display_name || u.name}</span>
<span style={{ fontWeight:600, fontSize:14, color: u.guardian_approval_required ? 'var(--error)' : 'var(--text-primary)' }}>{u.display_name || u.name}</span>
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
<span className={`role-badge role-${u.role}`}>{u.role}</span>
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
{!!u.guardian_approval_required && <span className="role-badge" style={{ background:'var(--error)', color:'white' }}>Pending Guardian Approval</span>}
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
</div>
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
@@ -129,7 +130,7 @@ function UserRow({ u, onUpdated, onEdit }) {
}
// ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) {
function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast();
const isEdit = !!user;
@@ -164,6 +165,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
if (!lastName.trim()) return toast('Last name is required', 'error');
if (!isValidPhone(phone)) return toast('Invalid phone number', 'error');
if (!['member', 'admin', 'manager'].includes(role)) return toast('Role is required', 'error');
if (loginType === 'mixed_age' && !dob) return toast('Date of birth is required in Mixed Age mode', 'error');
if (isEdit && pwEnabled && (!password || password.length < 6))
return toast('New password must be at least 6 characters', 'error');
@@ -171,10 +173,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
try {
if (isEdit) {
await api.updateUser(user.id, {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
role,
dateOfBirth: dob || undefined,
guardianUserId: guardianId || undefined,
...(pwEnabled && password ? { password } : {}),
});
// Sync group memberships: add newly selected, remove deselected
@@ -187,11 +191,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
toast('User updated', 'success');
} else {
const { user: newUser } = await api.createUser({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
role,
dateOfBirth: dob || undefined,
...(password ? { password } : {}),
});
// Add to selected groups
@@ -278,24 +283,42 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
</div>
</div>
{/* Row 4: DOB + Guardian */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', false, '(optional)')}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }} />
{/* Row 4: DOB + Guardian — visible when loginType is not 'all_ages' */}
{loginType !== 'all_ages' && (
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', loginType === 'mixed_age', loginType === 'guardian_only' ? '(optional)' : undefined)}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)}
autoComplete="off" onFocus={onIF} onBlur={onIB} />
</div>
{loginType === 'mixed_age' && isEdit && (
<div>
{lbl('Guardian', false, '(optional)')}
<div style={{ position:'relative' }}>
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
style={ user?.guardian_approval_required ? { borderColor:'var(--error)' } : {} }>
<option value=""> None </option>
{(nonMinorUsers || []).map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
{user?.guardian_approval_required && (
<div style={{ display:'flex', alignItems:'center', gap:8, marginTop:6 }}>
<span style={{ fontSize:12, color:'var(--error)', fontWeight:600 }}>Pending approval</span>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--success)', background:'none', border:'1px solid var(--success)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.approveGuardian(user.id); toast('Approved', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Approve
</button>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--error)', background:'none', border:'1px solid var(--error)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.denyGuardian(user.id); toast('Denied', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Deny
</button>
</div>
)}
</div>
)}
</div>
<div>
{lbl('Guardian', false, '(optional)')}
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }}>
<option value=""> Select guardian </option>
</select>
</div>
</div>
)}
{/* Row 4b: User Groups */}
{allUserGroups?.length > 0 && (
@@ -543,6 +566,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
const [editUser, setEditUser] = useState(null);
const [userPass, setUserPass] = useState('user@1234');
const [allUserGroups, setAllUserGroups] = useState([]);
const [loginType, setLoginType] = useState('all_ages');
const [inputFocused, setInputFocused] = useState(false);
const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false);
@@ -556,7 +580,10 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
useEffect(() => {
load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
api.getSettings().then(({ settings }) => {
if (settings.user_pass) setUserPass(settings.user_pass);
setLoginType(settings.feature_login_type || 'all_ages');
}).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {});
}, [load]);
@@ -664,6 +691,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
user={view === 'edit' ? editUser : null}
userPass={userPass}
allUserGroups={allUserGroups}
nonMinorUsers={users.filter(u => !u.is_minor && u.status === 'active')}
loginType={loginType}
onDone={() => { load(); goList(); }}
onCancel={goList}
isMobile={isMobile}

View File

@@ -69,6 +69,19 @@ export const api = {
const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form);
},
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
// Guardian aliases
getAliases: () => req('GET', '/users/me/aliases'),
createAlias: (body) => req('POST', '/users/me/aliases', body),
updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body),
deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),
uploadAliasAvatar: (aliasId, file) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', `/users/me/aliases/${aliasId}/avatar`, form);
},
// Groups
getGroups: () => req('GET', '/groups'),
@@ -105,6 +118,7 @@ export const api = {
registerCode: (code) => req('POST', '/settings/register', { code }),
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
updateMessageSettings: (body) => req('PATCH', '/settings/messages', body),
updateLoginType: (body) => req('PATCH', '/settings/login-type', body),
// Schedule Manager
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
@@ -120,9 +134,9 @@ export const api = {
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }),
setAvailability: (id, response, note) => req('PUT', `/schedule/${id}/availability`, { response, note }),
setAvailability: (id, response, note, aliasId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}) }),
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
deleteAvailability: (id, aliasId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : ''}`),
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => {