v0.12.43 minor protection added
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user