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

@@ -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">