From 97b308e9f0d5a33c3e9f94988a1fc50f558e326d Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 12:50:50 -0400 Subject: [PATCH] v0.12.51 updated "Mixed Age" login type. --- backend/package.json | 2 +- backend/src/routes/groups.js | 28 +- backend/src/routes/users.js | 64 ++- build.sh | 2 +- frontend/package.json | 2 +- .../src/components/AddChildAliasModal.jsx | 381 ++++++++++++------ frontend/src/components/NewChatModal.jsx | 64 ++- frontend/src/pages/Chat.jsx | 15 +- frontend/src/utils/api.js | 3 + 9 files changed, 398 insertions(+), 163 deletions(-) diff --git a/backend/package.json b/backend/package.json index 5a1bbfc..b8ec0ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.50", + "version": "0.12.51", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index a913e62..8594194 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -190,10 +190,10 @@ router.post('/', authMiddleware, async (req, res) => { await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]); await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]); - // Mixed Age: if the other user is a minor, auto-add their guardian + // Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian let guardianAdded = false, guardianName = null; const loginType = await getLoginType(req.schema); - if (loginType === 'mixed_age') { + if (loginType === 'mixed_age' && !req.user.is_minor) { const otherUserFull = await queryOne(req.schema, 'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]); if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) { @@ -234,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => { [name, type||'private', req.user.id, !!isReadonly] ); const groupId = r.rows[0].id; + const groupGuardianNames = []; if (type === 'public') { const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'"); for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]); @@ -251,9 +252,30 @@ router.post('/', authMiddleware, async (req, res) => { if (parseInt(totalCount.cnt) >= 3) { await computeAndStoreComposite(req.schema, groupId); } + + // Mixed Age: auto-add guardians for any minor members (non-minor initiators only) + const groupLoginType = await getLoginType(req.schema); + if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) { + for (const uid of memberIds) { + const memberInfo = await queryOne(req.schema, + 'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]); + if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) { + await exec(req.schema, + 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [groupId, memberInfo.guardian_user_id]); + const g = await queryOne(req.schema, + 'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]); + const gName = g?.display_name || g?.name; + if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName); + } + } + } } await emitGroupNew(req.schema, io, groupId); - res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) }); + res.json({ + group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]), + ...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}), + }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index f886d6f..3dc6331 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -80,18 +80,18 @@ router.get('/search', authMiddleware, async (req, res) => { const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]); if (group && (group.type === 'private' || group.is_direct)) { users = await query(req.schema, - `SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`, [parseInt(groupId), req.user.id, `%${q}%`] ); } else { users = await query(req.schema, - `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, [req.user.id, `%${q}%`] ); } } else { users = await query(req.schema, - `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, + `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, [`%${q}%`] ); } @@ -695,6 +695,64 @@ router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async } catch (e) { res.status(500).json({ error: e.message }); } }); +// List minor players available for this guardian to claim (Mixed Age — Family Manager) +// Returns minors in the players group who either have no guardian yet or are already linked to me. +router.get('/minor-players', authMiddleware, async (req, res) => { + try { + const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'"); + const playersGroupId = parseInt(playersRow?.value); + if (!playersGroupId) return res.json({ users: [] }); + const users = await query(req.schema, + `SELECT u.id,u.name,u.first_name,u.last_name,u.date_of_birth,u.avatar,u.status,u.guardian_user_id + FROM users u + JOIN user_group_members ugm ON ugm.user_id=u.id AND ugm.user_group_id=$1 + WHERE u.is_minor=TRUE AND u.status!='deleted' + AND (u.guardian_user_id IS NULL OR u.guardian_user_id=$2) + ORDER BY u.first_name,u.last_name`, + [playersGroupId, req.user.id] + ); + res.json({ users }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed) +router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => { + const minorId = parseInt(req.params.minorId); + try { + const minor = await queryOne(req.schema, "SELECT * FROM users WHERE id=$1 AND status!='deleted'", [minorId]); + if (!minor) return res.status(404).json({ error: 'User not found' }); + if (!minor.is_minor) return res.status(400).json({ error: 'User is not a minor' }); + if (minor.guardian_user_id && minor.guardian_user_id !== req.user.id) + return res.status(409).json({ error: 'This minor already has a guardian' }); + await exec(req.schema, + "UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$2", + [req.user.id, minorId] + ); + await addUserToPublicGroups(req.schema, minorId); + const user = await queryOne(req.schema, + 'SELECT id,name,first_name,last_name,date_of_birth,avatar,status,guardian_user_id FROM users WHERE id=$1', + [minorId] + ); + res.json({ user }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// Remove minor from guardian's list (Mixed Age — re-suspends the minor) +router.delete('/me/guardian-children/:minorId', authMiddleware, async (req, res) => { + const minorId = parseInt(req.params.minorId); + try { + const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]); + if (!minor) return res.status(404).json({ error: 'User not found' }); + if (minor.guardian_user_id !== req.user.id) + return res.status(403).json({ error: 'You are not the guardian of this user' }); + await exec(req.schema, + "UPDATE users SET guardian_user_id=NULL,status='suspended',updated_at=NOW() WHERE id=$1", + [minorId] + ); + res.json({ success: true }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + // Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval) router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => { const minorId = parseInt(req.params.minorId); diff --git a/build.sh b/build.sh index 10e3fe6..8fe248e 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.50}" +VERSION="${1:-0.12.51}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 1c951f3..c353ee8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.50", + "version": "0.12.51", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index a9b3b4e..cb0402e 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -3,16 +3,25 @@ import { useToast } from '../contexts/ToastContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx'; import { api } from '../utils/api.js'; -export default function AddChildAliasModal({ onClose }) { +export default function AddChildAliasModal({ features = {}, onClose }) { const toast = useToast(); const { user: currentUser } = useAuth(); + const loginType = features.loginType || 'guardian_only'; + const isMixedAge = loginType === 'mixed_age'; + + // ── Guardian-only state (alias form) ────────────────────────────────────── const [aliases, setAliases] = useState([]); - const [editingAlias, setEditingAlias] = useState(null); // null = new entry + const [editingAlias, setEditingAlias] = useState(null); const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); const [avatarFile, setAvatarFile] = useState(null); const [saving, setSaving] = useState(false); - // Partner state + // ── Mixed-age state (real minor users) ──────────────────────────────────── + const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine + const [selectedMinorId, setSelectedMinorId] = useState(''); + const [addingMinor, setAddingMinor] = useState(false); + + // ── Partner state (shared) ──────────────────────────────────────────────── const [partner, setPartner] = useState(null); const [selectedPartnerId, setSelectedPartnerId] = useState(''); const [respondSeparately, setRespondSeparately] = useState(false); @@ -20,20 +29,27 @@ export default function AddChildAliasModal({ onClose }) { const [savingPartner, setSavingPartner] = useState(false); useEffect(() => { - Promise.all([ - api.getAliases(), - api.getPartner(), - api.searchUsers(''), - ]).then(([aliasRes, partnerRes, usersRes]) => { - setAliases(aliasRes.aliases || []); + const loads = [api.getPartner(), api.searchUsers('')]; + if (isMixedAge) { + loads.push(api.getMinorPlayers()); + } else { + loads.push(api.getAliases()); + } + Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => { const p = partnerRes.partner || null; setPartner(p); setSelectedPartnerId(p?.id?.toString() || ''); setRespondSeparately(p?.respond_separately || false); setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id)); + if (isMixedAge) { + setMinorPlayers(thirdRes.users || []); + } else { + setAliases(thirdRes.aliases || []); + } }).catch(() => {}); - }, []); + }, [isMixedAge]); + // ── Helpers ─────────────────────────────────────────────────────────────── const set = k => e => setForm(p => ({ ...p, [k]: e.target.value })); const resetForm = () => { @@ -42,6 +58,47 @@ export default function AddChildAliasModal({ onClose }) { setAvatarFile(null); }; + const lbl = (text, required) => ( + + ); + + // ── Partner handlers ────────────────────────────────────────────────────── + const handleSavePartner = async () => { + setSavingPartner(true); + try { + if (!selectedPartnerId) { + await api.removePartner(); + setPartner(null); + setRespondSeparately(false); + if (!isMixedAge) { + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + resetForm(); + } else { + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + } + toast('Spouse/Partner/Co-Parent removed', 'success'); + } else { + const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately); + setPartner(p); + setRespondSeparately(p?.respond_separately || false); + if (!isMixedAge) { + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + } + toast('Spouse/Partner/Co-Parent saved', 'success'); + } + } catch (e) { + toast(e.message, 'error'); + } finally { + setSavingPartner(false); + } + }; + + // ── Guardian-only alias handlers ────────────────────────────────────────── const handleSelectAlias = (a) => { if (editingAlias?.id === a.id) { resetForm(); return; } setEditingAlias(a); @@ -55,33 +112,7 @@ export default function AddChildAliasModal({ onClose }) { setAvatarFile(null); }; - const handleSavePartner = async () => { - setSavingPartner(true); - try { - if (!selectedPartnerId) { - await api.removePartner(); - setPartner(null); - setRespondSeparately(false); - const { aliases: fresh } = await api.getAliases(); - setAliases(fresh || []); - resetForm(); - toast('Spouse/Partner/Co-Parent removed', 'success'); - } else { - const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately); - setPartner(p); - setRespondSeparately(p?.respond_separately || false); - const { aliases: fresh } = await api.getAliases(); - setAliases(fresh || []); - toast('Spouse/Partner/Co-Parent saved', 'success'); - } - } catch (e) { - toast(e.message, 'error'); - } finally { - setSavingPartner(false); - } - }; - - const handleSave = async () => { + const handleSaveAlias = async () => { if (!form.firstName.trim() || !form.lastName.trim()) return toast('First and last name required', 'error'); setSaving(true); @@ -117,7 +148,7 @@ export default function AddChildAliasModal({ onClose }) { } }; - const handleDelete = async (e, aliasId) => { + const handleDeleteAlias = async (e, aliasId) => { e.stopPropagation(); try { await api.deleteAlias(aliasId); @@ -127,11 +158,35 @@ export default function AddChildAliasModal({ onClose }) { } catch (err) { toast(err.message, 'error'); } }; - const lbl = (text, required) => ( - - ); + // ── Mixed-age minor handlers ────────────────────────────────────────────── + const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id); + const availableMinors = minorPlayers.filter(u => !u.guardian_user_id); + + const handleAddMinor = async () => { + if (!selectedMinorId) return; + setAddingMinor(true); + try { + await api.addGuardianChild(parseInt(selectedMinorId)); + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + setSelectedMinorId(''); + toast('Child added and account activated', 'success'); + } catch (e) { + toast(e.message, 'error'); + } finally { + setAddingMinor(false); + } + }; + + const handleRemoveMinor = async (e, minorId) => { + e.stopPropagation(); + try { + await api.removeGuardianChild(minorId); + const { users: fresh } = await api.getMinorPlayers(); + setMinorPlayers(fresh || []); + toast('Child removed', 'success'); + } catch (err) { toast(err.message, 'error'); } + }; return (
e.target === e.currentTarget && onClose()}> @@ -187,93 +242,169 @@ export default function AddChildAliasModal({ onClose }) { )}
- {/* Existing aliases list */} - {aliases.length > 0 && ( -
-
- Your Children — click to edit -
-
- {aliases.map((a, i) => ( -
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', - }} - > - - {a.first_name} {a.last_name} - - {a.date_of_birth && ( - - {a.date_of_birth.slice(0, 10)} - - )} - + {/* ── Mixed Age: link real minor users ── */} + {isMixedAge && ( + <> + {/* Current children list */} + {myMinors.length > 0 && ( +
+
+ Your Children
- ))} +
+ {myMinors.map((u, i) => ( +
+ {u.first_name} {u.last_name} + {u.date_of_birth && ( + + {u.date_of_birth.slice(0, 10)} + + )} + +
+ ))} +
+
+ )} + + {/* Add minor from players group */} +
+ Add Child
-
+
+ + +
+ {availableMinors.length === 0 && myMinors.length === 0 && ( +

+ No minor players available to link. +

+ )} + )} - {/* Form section label */} -
- {editingAlias - ? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}` - : 'Add Child'} -
- - {/* Form */} -
-
-
- {lbl('First Name', true)} - -
-
- {lbl('Last Name', true)} - -
-
- {lbl('Date of Birth')} - -
-
- {lbl('Phone')} - -
-
-
- {lbl('Email (optional)')} - -
-
- {lbl('Avatar (optional)')} - setAvatarFile(e.target.files?.[0] || null)} /> -
-
- {editingAlias && ( - + {/* ── Guardian Only: alias form ── */} + {!isMixedAge && ( + <> + {/* Existing aliases list */} + {aliases.length > 0 && ( +
+
+ Your Children — click to edit +
+
+ {aliases.map((a, i) => ( +
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', + }} + > + + {a.first_name} {a.last_name} + + {a.date_of_birth && ( + + {a.date_of_birth.slice(0, 10)} + + )} + +
+ ))} +
+
)} - -
-
+ + {/* Form section label */} +
+ {editingAlias + ? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}` + : 'Add Child'} +
+ + {/* Form */} +
+
+
+ {lbl('First Name', true)} + +
+
+ {lbl('Last Name', true)} + +
+
+ {lbl('Date of Birth')} + +
+
+ {lbl('Phone')} + +
+
+
+ {lbl('Email (optional)')} + +
+
+ {lbl('Avatar (optional)')} + setAvatarFile(e.target.files?.[0] || null)} /> +
+
+ {editingAlias && ( + + )} + +
+
+ + )}
diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx index 69a4da2..4945798 100644 --- a/frontend/src/components/NewChatModal.jsx +++ b/frontend/src/components/NewChatModal.jsx @@ -11,6 +11,7 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { const msgPublic = features.msgPublic ?? true; const msgU2U = features.msgU2U ?? true; const msgPrivateGroup = features.msgPrivateGroup ?? true; + const loginType = features.loginType || 'all_ages'; // Default to private if available, otherwise public const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public'; @@ -21,9 +22,8 @@ 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'; + // Pre-confirmation for minor members (shown before creating the chat) + const [minorConfirm, setMinorConfirm] = useState(null); // { minorNames: [] } — pending create // True when exactly 1 user selected on private tab AND U2U messages are enabled const isDirect = tab === 'private' && selected.length === 1 && msgU2U; @@ -48,16 +48,11 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { }); }; - const handleCreate = async () => { - if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error'); - if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error'); - if (tab === 'public' && !name.trim()) return toast('Name required', 'error'); - + const doCreate = async () => { setLoading(true); try { let payload; if (isDirect) { - // Direct message: no name, isDirect flag payload = { type: 'private', memberIds: selected.map(u => u.id), @@ -72,18 +67,16 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { }; } - const { group, duplicate, guardianAdded, guardianName } = await api.createGroup(payload); + const { group, duplicate, guardianAdded } = 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); + if (guardianAdded) { + toast('A guardian has been added to this conversation.', 'info'); } } + onCreated(group); } catch (e) { toast(e.message, 'error'); } finally { @@ -91,6 +84,23 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { } }; + const handleCreate = async () => { + if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error'); + if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error'); + if (tab === 'public' && !name.trim()) return toast('Name required', 'error'); + + // Mixed Age: warn if any selected member is a minor (and initiator is not a minor) + if (loginType === 'mixed_age' && !user.is_minor) { + const minors = selected.filter(u => u.is_minor); + if (minors.length > 0) { + setMinorConfirm({ minorNames: minors.map(u => u.display_name || u.name) }); + return; + } + } + + await doCreate(); + }; + // Placeholder for the name field const namePlaceholder = isDirect ? selected[0]?.name || '' @@ -181,15 +191,25 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) { - {guardianConfirm && ( + {/* Pre-confirmation modal: minor member warning */} + {minorConfirm && (
-
-

Guardian Added

-

- {guardianConfirm.guardianName} has been added to this conversation as the guardian of this minor. +

+

Guardian Notice

+

+ The following member{minorConfirm.minorNames.length > 1 ? 's are' : ' is'} a minor:

-
- +
    + {minorConfirm.minorNames.map(n => ( +
  • {n}
  • + ))} +
+

+ Their designated guardian(s) will be automatically added to this conversation. Do you want to proceed? +

+
+ +
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index edd33df..5804a03 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -120,10 +120,11 @@ export default function Chat() { // 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 + // Auto-popup Add Child Alias modal when guardian user has no children yet useEffect(() => { if (addChildCheckedRef.current) return; - if (features.loginType !== 'guardian_only' || !features.inGuardiansGroup) return; + if (!features.inGuardiansGroup) return; + if (features.loginType !== 'guardian_only' && features.loginType !== 'mixed_age') return; addChildCheckedRef.current = true; api.getAliases().then(({ aliases }) => { if (!(aliases || []).length) { @@ -644,7 +645,7 @@ export default function Chat() { {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} {modal === 'help' && } - {modal === 'addchild' && setModal(null)} />} + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />}
@@ -675,7 +676,7 @@ export default function Chat() { {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} {modal === 'help' && } - {modal === 'addchild' && setModal(null)} />} + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />}
@@ -736,7 +737,7 @@ export default function Chat() { {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} {modal === 'help' && } - {modal === 'addchild' && setModal(null)} />} + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />} {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />} @@ -772,7 +773,7 @@ export default function Chat() { {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} {modal === 'help' && } - {modal === 'addchild' && setModal(null)} />} + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />} @@ -890,7 +891,7 @@ export default function Chat() { {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'about' && setModal(null)} />} {modal === 'help' && } - {modal === 'addchild' && setModal(null)} />} + {modal === 'addchild' && setModal(null)} />} ); } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 80b77cc..a809877 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -70,6 +70,9 @@ export const api = { return req('POST', '/users/me/avatar', form); }, searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`), + getMinorPlayers: () => req('GET', '/users/minor-players'), + addGuardianChild: (minorId) => req('POST', `/users/me/guardian-children/${minorId}`), + removeGuardianChild: (minorId) => req('DELETE', `/users/me/guardian-children/${minorId}`), 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}`),