Compare commits

...

2 Commits

16 changed files with 917 additions and 105 deletions

View File

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

View File

@@ -0,0 +1,41 @@
-- 015_minor_age_protection.sql
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
CREATE TABLE IF NOT EXISTS guardian_aliases (
id SERIAL PRIMARY KEY,
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
date_of_birth DATE,
avatar TEXT,
phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
CREATE TABLE IF NOT EXISTS alias_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_group_id, alias_id)
);
-- 4. event_alias_availability — availability responses for guardian aliases
CREATE TABLE IF NOT EXISTS event_alias_availability (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
note VARCHAR(20),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, alias_id)
);
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);

View File

@@ -4,6 +4,11 @@ const router = express.Router();
const { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
async function getLoginType(schema) {
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
return row?.value || 'all_ages';
}
function deleteImageFile(imageUrl) {
if (!imageUrl) return;
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
@@ -67,7 +72,7 @@ router.get('/', authMiddleware, async (req, res) => {
`);
const privateGroupsRaw = await query(req.schema, `
SELECT g.*, u.name AS owner_name,
SELECT g.*, u.name AS owner_name, ug.id AS source_user_group_id,
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
@@ -80,7 +85,9 @@ router.get('/', authMiddleware, async (req, res) => {
ORDER BY u2.name LIMIT 4
) t) AS member_previews
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
LEFT JOIN users u ON g.owner_id=u.id
LEFT JOIN user_groups ug ON ug.dm_group_id=g.id AND g.is_managed=TRUE AND g.is_multi_group IS NOT TRUE
WHERE g.type='private'
ORDER BY last_message_at DESC NULLS LAST
`, [userId]);
@@ -182,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => {
const groupId = r.rows[0].id;
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
let guardianAdded = false, guardianName = null;
const loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age') {
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) {
const guardianId = otherUserFull.guardian_user_id;
if (guardianId !== userId) {
await exec(req.schema,
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[groupId, guardianId]);
const guardian = await queryOne(req.schema,
'SELECT name, display_name FROM users WHERE id=$1', [guardianId]);
guardianAdded = true;
guardianName = guardian?.display_name || guardian?.name || null;
}
}
}
await emitGroupNew(req.schema, io, groupId);
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
return res.json({ group, guardianAdded, guardianName });
}
// Check for duplicate private group

View File

@@ -241,10 +241,18 @@ router.get('/:id', authMiddleware, async (req, res) => {
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]));
if (event.track_availability && (itm || isMember)) {
event.availability = await query(req.schema, `
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar
// User responses
const userAvail = await query(req.schema, `
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar, FALSE AS is_alias
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]);
// Alias responses (Guardian Only mode)
const aliasAvail = await query(req.schema, `
SELECT eaa.response, eaa.note, eaa.updated_at, ga.id AS alias_id, ga.first_name, ga.last_name, ga.avatar, ga.guardian_id, TRUE AS is_alias
FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
`, [req.params.id]);
event.availability = [...userAvail, ...aliasAvail];
if (itm) {
const assignedRows = await query(req.schema, `
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name
@@ -253,11 +261,42 @@ router.get('/:id', authMiddleware, async (req, res) => {
JOIN users u ON u.id=ugm.user_id
WHERE eug.event_id=$1
`, [req.params.id]);
const respondedIds = new Set(event.availability.map(r => r.user_id));
const noResponseRows = assignedRows.filter(r => !respondedIds.has(r.user_id));
// Also include alias members
const assignedAliases = await query(req.schema, `
SELECT DISTINCT ga.id AS alias_id, ga.first_name, ga.last_name
FROM event_user_groups eug
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
JOIN guardian_aliases ga ON ga.id=agm.alias_id
WHERE eug.event_id=$1
`, [req.params.id]);
const respondedUserIds = new Set(userAvail.map(r => r.user_id));
const respondedAliasIds = new Set(aliasAvail.map(r => r.alias_id));
const noResponseRows = [
...assignedRows.filter(r => !respondedUserIds.has(r.user_id)),
...assignedAliases.filter(r => !respondedAliasIds.has(r.alias_id)).map(r => ({ ...r, is_alias: true })),
];
event.no_response_count = noResponseRows.length;
event.no_response_users = noResponseRows;
}
// Detect if event targets the players group (for responder select dropdown)
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
const playersGroupId = parseInt(playersRow?.value);
event.has_players_group = !!(playersGroupId && event.user_groups?.some(g => g.id === playersGroupId));
// Detect if event targets the guardians group (so guardian shows own name in select)
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
const guardiansGroupId = parseInt(guardiansRow?.value);
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id])));
// Return current user's aliases for the responder dropdown (Guardian Only)
if (event.has_players_group) {
event.my_aliases = await query(req.schema,
'SELECT id,first_name,last_name,avatar FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
[req.user.id]
);
}
}
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
event.my_response = mine?.response || null;
@@ -564,19 +603,31 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
const { response, note } = req.body;
const { response, note, aliasId } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, req.user.id, response, trimmedNote]);
if (aliasId) {
// Alias response (Guardian Only mode) — verify alias belongs to current user
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
await exec(req.schema, `
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, aliasId, response, trimmedNote]);
} else {
// Regular user response
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, req.user.id]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, req.user.id, response, trimmedNote]);
}
res.json({ success: true, response, note: trimmedNote });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -593,7 +644,14 @@ router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
router.delete('/:id/availability', authMiddleware, async (req, res) => {
try {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
const { aliasId } = req.query;
if (aliasId) {
const alias = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
} else {
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
}
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});

View File

@@ -152,6 +152,26 @@ router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
const VALID_LOGIN_TYPES = ['all_ages', 'guardian_only', 'mixed_age'];
router.patch('/login-type', authMiddleware, adminMiddleware, async (req, res) => {
const { loginType, playersGroupId, guardiansGroupId } = req.body;
if (!VALID_LOGIN_TYPES.includes(loginType)) return res.status(400).json({ error: 'Invalid login type' });
try {
// Enforce: can only change when no non-admin users exist, UNLESS staying on same value
const existing = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_login_type'");
const current = existing?.value || 'all_ages';
if (loginType !== current) {
const { count } = await queryOne(req.schema, "SELECT COUNT(*)::int AS count FROM users WHERE role != 'admin' AND status != 'deleted'");
if (count > 0) return res.status(400).json({ error: 'Login Type can only be changed when no non-admin users exist.' });
}
await setSetting(req.schema, 'feature_login_type', loginType);
await setSetting(req.schema, 'feature_players_group_id', playersGroupId != null ? String(playersGroupId) : '');
await setSetting(req.schema, 'feature_guardians_group_id', guardiansGroupId != null ? String(guardiansGroupId) : '');
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
const { toolManagers } = req.body;
try {

View File

@@ -16,6 +16,17 @@ const uploadAvatar = multer({
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
// Alias avatar upload (separate from user avatar so filename doesn't collide)
const aliasAvatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => cb(null, `alias_${req.params.aliasId}_${Date.now()}${path.extname(file.originalname)}`),
});
const uploadAliasAvatar = multer({
storage: aliasAvatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
});
async function resolveUniqueName(schema, baseName, excludeId = null) {
const existing = await query(schema,
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
@@ -29,11 +40,28 @@ async function resolveUniqueName(schema, baseName, excludeId = null) {
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
// Returns true if the given date-of-birth string corresponds to age <= 15
function isMinorFromDOB(dob) {
if (!dob) return false;
const birth = new Date(dob);
if (isNaN(birth)) return false;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const m = today.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
return age <= 15;
}
async function getLoginType(schema) {
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
return row?.value || 'all_ages';
}
// List users
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try {
const users = await query(req.schema,
"SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
@@ -86,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
if (!firstName?.trim() || !lastName?.trim() || !email)
return res.status(400).json({ error: 'First name, last name and email required' });
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
@@ -94,45 +122,74 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const assignedRole = validRoles.includes(role) ? role : 'member';
const name = `${firstName.trim()} ${lastName.trim()}`;
try {
const loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age' && !dateOfBirth)
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
// In mixed_age mode, minors start suspended and need guardian approval
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema,
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id",
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
);
const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId);
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
if (assignedRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema);
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
}
const user = await queryOne(req.schema, 'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
res.json({ user });
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
[userId]
);
res.json({ user, pendingApproval: initStatus === 'suspended' });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user (general — name components, phone, is_minor, role, optional password reset)
// Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
const { firstName, lastName, phone, isMinor, role, password } = req.body;
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
if (!firstName?.trim() || !lastName?.trim())
return res.status(400).json({ error: 'First and last name required' });
const validRoles = ['member', 'admin', 'manager'];
if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
try {
const loginType = await getLoginType(req.schema);
if (loginType === 'mixed_age' && !dateOfBirth)
return res.status(400).json({ error: 'Date of birth is required in Mixed Age mode' });
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin && role !== 'admin')
return res.status(403).json({ error: 'Cannot change default admin role' });
const name = `${firstName.trim()} ${lastName.trim()}`;
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
const name = `${firstName.trim()} ${lastName.trim()}`;
const resolvedName = await resolveUniqueName(req.schema, name, id);
// Validate guardian if provided
let guardianId = null;
if (guardianUserId) {
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
guardianId = gUser.id;
}
await exec(req.schema,
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7',
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, !!isMinor, role, id]
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
);
if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10);
@@ -143,7 +200,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
}
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
[id]
);
res.json({ user });
@@ -324,7 +381,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
// Update own profile
router.patch('/me/profile', authMiddleware, async (req, res) => {
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
try {
if (displayName) {
const conflict = await queryOne(req.schema,
@@ -333,12 +390,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
);
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
}
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
await exec(req.schema,
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
);
const user = await queryOne(req.schema,
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
[req.user.id]
);
res.json({ user });
@@ -376,4 +435,173 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
}
});
// ── Guardian alias routes (Guardian Only mode) ──────────────────────────────
// List current user's aliases
router.get('/me/aliases', authMiddleware, async (req, res) => {
try {
const aliases = await query(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE guardian_id=$1 ORDER BY first_name,last_name',
[req.user.id]
);
res.json({ aliases });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Create alias
router.post('/me/aliases', authMiddleware, async (req, res) => {
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
try {
const r = await queryResult(req.schema,
'INSERT INTO guardian_aliases (guardian_id,first_name,last_name,email,date_of_birth,phone) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id',
[req.user.id, firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null]
);
const aliasId = r.rows[0].id;
// Auto-add alias to players group if designated
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
const playersGroupId = parseInt(playersRow?.value);
if (playersGroupId) {
await exec(req.schema,
'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[playersGroupId, aliasId]
);
}
const alias = await queryOne(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
[aliasId]
);
res.json({ alias });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update alias
router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
await exec(req.schema,
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
[firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null, aliasId]
);
const alias = await queryOne(req.schema,
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
[aliasId]
);
res.json({ alias });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Delete alias
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Upload alias avatar
router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.single('avatar'), async (req, res) => {
const aliasId = parseInt(req.params.aliasId);
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const existing = await queryOne(req.schema, 'SELECT id FROM guardian_aliases WHERE id=$1 AND guardian_id=$2', [aliasId, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Alias not found' });
const sharp = require('sharp');
const filePath = req.file.path;
const MAX_DIM = 256;
const image = sharp(filePath);
const meta = await image.metadata();
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
let avatarUrl;
if (req.file.size >= 500 * 1024 || needsResize) {
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
require('fs').unlinkSync(filePath);
avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
} else {
avatarUrl = `/uploads/avatars/${req.file.filename}`;
}
await exec(req.schema, 'UPDATE guardian_aliases SET avatar=$1,updated_at=NOW() WHERE id=$2', [avatarUrl, aliasId]);
res.json({ avatarUrl });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Search minor users (Mixed Age — for Add Child in profile)
router.get('/search-minors', authMiddleware, async (req, res) => {
const { q } = req.query;
try {
const users = await query(req.schema,
`SELECT id,name,first_name,last_name,date_of_birth,avatar,phone FROM users
WHERE is_minor=TRUE AND status='suspended' AND guardian_user_id IS NULL AND status!='deleted'
AND (name ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1)
ORDER BY name ASC LIMIT 20`,
[`%${q || ''}%`]
);
res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Approve guardian link (Mixed Age — manager+ sets guardian, clears approval flag, unsuspends)
router.patch('/:id/approve-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!minor) return res.status(404).json({ error: 'User not found' });
if (!minor.guardian_approval_required) return res.status(400).json({ error: 'No pending approval' });
await exec(req.schema,
"UPDATE users SET guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$1",
[id]
);
await addUserToPublicGroups(req.schema, id);
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
[id]
);
res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Deny guardian link (Mixed Age — clears guardian, keeps suspended)
router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
if (!minor) return res.status(404).json({ error: 'User not found' });
await exec(req.schema,
'UPDATE users SET guardian_approval_required=FALSE,guardian_user_id=NULL,updated_at=NOW() WHERE id=$1',
[id]
);
const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
[id]
);
res.json({ user });
} 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);
try {
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
if (!minor) return res.status(404).json({ error: 'Minor user not found' });
if (!minor.is_minor) return res.status(400).json({ error: 'User is not flagged as a minor' });
if (minor.guardian_user_id && !minor.guardian_approval_required)
return res.status(400).json({ error: 'This minor already has an approved guardian' });
await exec(req.schema,
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=TRUE,updated_at=NOW() WHERE id=$2',
[req.user.id, minorId]
);
res.json({ success: true, pendingApproval: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
module.exports = router;

View File

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

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) => {