From fe836ae69f13b606e671f6f8db14d3b6d9a91c92 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Mon, 30 Mar 2026 16:02:09 -0400 Subject: [PATCH 01/20] v0.12.43 minor protection added --- FEATURES.md | 152 ++++++++++ Reference/minor-age-protection.txt | 63 +++++ backend/package.json | 2 +- .../migrations/015_minor_age_protection.sql | 41 +++ backend/src/routes/groups.js | 35 ++- backend/src/routes/schedule.js | 90 ++++-- backend/src/routes/settings.js | 20 ++ backend/src/routes/users.js | 262 ++++++++++++++++-- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/NewChatModal.jsx | 26 +- frontend/src/components/ProfileModal.jsx | 235 ++++++++++++++-- frontend/src/components/SchedulePage.jsx | 62 ++++- frontend/src/components/SettingsModal.jsx | 138 ++++++++- frontend/src/components/Sidebar.jsx | 4 + frontend/src/pages/Chat.jsx | 2 + frontend/src/pages/UserManagerPage.jsx | 83 ++++-- frontend/src/utils/api.js | 18 +- 18 files changed, 1132 insertions(+), 105 deletions(-) create mode 100644 FEATURES.md create mode 100644 Reference/minor-age-protection.txt create mode 100644 backend/src/models/migrations/015_minor_age_protection.sql diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..e39a0de --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,152 @@ +# RosterChirp — Feature Reference + +> **Current version:** 0.12.42 +> **Application types:** RosterChirp-Chat · RosterChirp-Brand · RosterChirp-Team + +--- + +## All Users + +### Messaging + +- **Public Messages** — Read and post in public group channels open to all members. Channels can be marked read-only by an admin (announcements-style). +- **Private Group Messages** — Participate in named private groups with a specific set of members. +- **Direct Messages (U2U)** — Start a private one-on-one conversation with any user who has not blocked direct messages. +- **Group Messages** — Access managed private group conversations assigned to you through User Groups (requires RosterChirp-Team). +- **Message History** — Scroll back through conversation history with paginated loading (50 messages per page). +- **Message Reactions** — React to any message with an emoji. +- **Image Sharing** — Attach and send images in any conversation. +- **Reply Threading** — Reply to a specific message to preserve context. +- **@Mentions** — Mention users by name; mentioned users receive a notification badge. +- **Link Previews** — URLs pasted into messages automatically generate a title/image preview card. +- **Message Deletion** — Authors can delete their own messages; deleted messages are replaced with a tombstone. + +### Schedule (requires RosterChirp-Team) + +- **Calendar View** — Browse events in a full monthly calendar grid (desktop) or a day-list view (mobile). +- **Event Details** — Tap any event to view its full details: date/time, location, description, event type, assigned user groups, and recurrence pattern. +- **Availability Response** — Respond to events with **Going**, **Maybe**, or **Not Going**, plus an optional short note (up to 20 characters). +- **Bulk Availability** — Respond to multiple pending events at once from a single screen. +- **Response Summary** — See how many group members have responded Going / Maybe / Not Going on any event you are assigned to. +- **Filter & Search** — Filter the calendar by event type, keyword, or your own availability status. Keyword search supports word-boundary matching and exact quoted terms. + +### Profile & Account + +- **Display Name** — Set a public display name shown alongside your username (must be unique). +- **Avatar** — Upload a custom profile photo. A consistent colour avatar is generated automatically from your name if no photo is set. +- **About Me** — Add a short bio visible on your profile. +- **Hide Admin Tag** — Admins can choose to hide the "Admin" role badge on their messages. +- **Block Direct Messages** — Opt out of receiving unsolicited direct messages from other users. +- **Change Password** — Change your own account password at any time. +- **Font Scale** — Adjust the interface text size (80%–200%) stored per-device. + +### Notifications & Presence + +- **Push Notifications** — Receive push notifications for new messages when the app is backgrounded. Supports Android (Firebase Cloud Messaging) and iOS 16.4+ PWA (Web Push / VAPID). +- **Notification Permission** — Grant or revoke push notification permission from the Notifications tab in your profile. +- **Unread Badges** — Conversations with unread messages display a count badge in the sidebar and on the PWA app icon. +- **Online Presence** — A green indicator shows which users are currently active. Last-seen time is displayed for offline users. +- **Browser Tab Badge** — The page title and PWA icon badge update with the total unread count across all conversations. + +### App Experience + +- **Progressive Web App (PWA)** — Install RosterChirp to your home screen on Android, iOS, and desktop for a native app feel. +- **Dark / Light Theme** — The interface respects your operating system's colour scheme preference automatically. +- **Mobile-Optimised Layout** — A dedicated mobile layout with a slide-in sidebar, swipe-back navigation, and mobile-native time/date pickers. +- **Keyboard Shortcuts** — Press Enter to send messages; Escape to dismiss modals. + +--- + +## Managers (Tool Managers) + +Tool Manager access is granted by an admin to members of one or more designated **User Groups**. Managers have access to the following tools in addition to all user features. + +### User Manager + +- **View All Users** — Browse the full user directory including email, role, phone, status, and last seen time. +- **Create Users** — Add individual new user accounts with name, email, role, and phone. +- **Bulk Import** — Import multiple users at once from a structured list (CSV-compatible). +- **Edit Users** — Update names, email addresses, phone numbers, and minor status for any user. +- **Suspend / Activate** — Suspend a user to block login without deleting their account or messages. Reversible at any time. +- **Reset Password** — Set a new temporary password for any user. + +### Group Manager (requires RosterChirp-Team) + +- **Create User Groups** — Create named user groups to organise members into teams or departments. +- **Manage Members** — Add or remove users from any user group. Member changes trigger a system notification in the group's conversation. +- **Multi-Groups** — Create a multi-group conversation that spans multiple user groups simultaneously. +- **Assign Schedule Groups** — Link user groups to schedule events to control who is invited and whose availability is tracked. + +### Schedule Manager (requires RosterChirp-Team) + +- **Create Events** — Create new calendar events with title, type, date/time, location, description, visibility (public/private), and assigned user groups. +- **Edit & Delete Events** — Modify or remove any event. Recurring events support editing/deleting a single occurrence, all future occurrences, or the entire series. +- **Recurring Events** — Schedule repeating events (daily, weekly, bi-weekly, monthly) with optional end date or occurrence count. Supports specific weekday selection for weekly recurrence. +- **Event Types** — Create and manage colour-coded event type categories (e.g. Training, Match, Meeting). +- **Track Availability** — Enable availability tracking on an event to collect Going / Maybe / Not Going responses from assigned group members. +- **View Full Responses** — See the complete list of who has responded and with what answer, including individual notes. The **No Response** count shows how many assigned members have not yet replied. +- **Download Availability List** — Export a formatted `.txt` file of all availability responses for an event, organised by section (Going, Maybe, Not Going, No Response) and sorted alphabetically by last name within each section. +- **Import Schedule** — Upload and preview a schedule import file, then confirm to bulk-create events. +- **Past Event Visibility** — View and manage past events in the calendar; past events are displayed in a greyed style. + +--- + +## Admins + +Admins have full access to all user and manager features plus the following administrative controls. + +### User Manager (extended) + +- **Delete Users** — Permanently scrub a user's account: email and name are anonymised, all their messages are marked deleted, and direct message threads become read-only. Frees the email address for re-registration immediately. +- **Assign Roles** — Promote or demote users between the **User**, **Manager**, and **Admin** roles. + +### Settings + +- **Message Features** — Enable or disable individual message channel types across the entire instance: Public Messages, Group Messages, Private Group Messages, and Private Messages (U2U). Disabled features are hidden from all menus, sidebars, and modals. +- **Registration** — Apply a registration code to unlock the application type (Chat / Brand / Team) and associated features. View the instance serial number and current registration status. + +### Branding (requires RosterChirp-Brand or higher) + +- **App Name** — Set a custom application name that appears in the header, browser tab, and push notifications. +- **Logo / Favicon** — Upload a custom logo used as the app header image and PWA icon (192×512 px generated automatically). +- **Header Colour** — Set custom header bar colours for light mode and dark mode independently. +- **Avatar Colours** — Customise the default avatar colours used for public channel icons and direct message icons. +- **Reset Branding** — Restore all branding settings to the default RosterChirp values in one click. + +### Team Configuration (requires RosterChirp-Team) + +- **Tool Manager Groups** — Designate one or more User Groups whose members are granted Tool Manager access (User Manager, Group Manager, Schedule Manager). Admins always have full access regardless of this setting. + +### Control Panel (Host mode only — admin on the host domain) + +- **Tenant Management** — View, create, suspend, and delete tenant instances from a central dashboard. +- **Assign Plans** — Set the application type (Chat / Brand / Team) for each tenant. +- **Custom Domains** — Assign a custom domain to a tenant in addition to its default subdomain. +- **Tenant Details** — View each tenant's slug, plan, status, custom domain, and creation date. + +--- + +## Hosting & Tenant Privacy + +RosterChirp supports two deployment modes configured via the `APP_TYPE` environment variable. + +### Self-Hosted (Single Tenant) + +`APP_TYPE=selfhost` — The default mode for teams running their own private instance. All data is stored in a single PostgreSQL schema. There are no subdomains or tenant concepts; the application runs at the root of whatever domain or IP the server is deployed on. + +### RosterChirp-Host (Multi-Tenant) + +`APP_TYPE=host` — Enables multi-tenant hosting from a single server. Each tenant is provisioned with: + +- **A unique slug** — for example, the slug `acme` creates a dedicated instance accessible at `acme.yourdomain.com`. The slug is set at provisioning time and forms the permanent subdomain for that tenant. +- **An isolated Postgres schema** — every tenant's data (users, messages, groups, events, settings) lives in its own named schema (`tenant_acme`, etc.) within the same database. No data is shared between tenants. +- **An optional custom domain** — a tenant can be mapped to a fully custom domain (e.g. `chat.acme.com`) in addition to its default subdomain. Custom domain lookups are cached for performance. +- **Plan-level feature control** — each tenant can be assigned a different application type (Chat / Brand / Team), enabling per-tenant feature gating from the host control panel. + +### Privacy & Isolation Guarantees + +- **Schema isolation** — all database queries are scoped to the tenant's schema. A query in one tenant's context cannot read or write another tenant's tables. +- **Socket room isolation** — all real-time socket rooms are prefixed with the tenant schema name (`acme:group:42`). Events emitted in one tenant's rooms cannot reach sockets in another tenant. +- **Online presence isolation** — the online user map is keyed by `schema:userId`, preventing user ID collisions between tenants from leaking presence data. +- **Session isolation** — JWT tokens are validated against the tenant schema. A valid token for one tenant is not accepted by another. +- **Host control plane separation** — the host admin control panel is only accessible on the host's own root domain, protected by a separate `HOST_ADMIN_KEY`, and hidden from all tenant subdomains. diff --git a/Reference/minor-age-protection.txt b/Reference/minor-age-protection.txt new file mode 100644 index 0000000..bbffa7e --- /dev/null +++ b/Reference/minor-age-protection.txt @@ -0,0 +1,63 @@ +RULE: minor age is when DOB is 15 years and under. + +User manager: +Enable "Date of Birth" field (default optional unless "Mixed Age" is selected in the (new) as "Login Type" on Settings modal page) +Enable "Guardian" field as optional + +My Profile modal: +Change: replace tab buttons with a select list labled "SELECT OPTION:" Profile - default selected (on both desktop and mobile) +On the "Profile" tab, add two new text inputs: Date of Birth and Phone (same format field verification as used in the user manager field) - once saved, user manager is updated with data from these two fields for the given user +Add a new select option "Add Child" (only displayed IF the new "Login Type" setting has either "Guardians Only" or "Mixed Age" selected. + +"Add Child" Form: + - (on user's my profile): Firstname, Lastname, Email, DOB, Profile avatar (follow avatar upload rules), number (input box), Add button, Save button (disabled until there is a child added to guardian's child list) + - Clicking the Add button will add child to the guardians child list.) + - Clicking save, will save the the guardian's child list and will each child entry to players user group. + +Settings modal: +Change: replace tab tabs buttons to a select list, labelled "SELECT OPTION" Messages is still the default option (on both desktop and mobile) +Add new selection "Login Type" to the list, form details below: + +An option list with brief description under: +Option: "Guardian Only" +Descriptions: "Parents are required to add their child's details in their profile. They will respond on behalf of the child for events with availability tracking for the "players" group". + + - User manager DOB is optional + - "Players" user group entry will be the child's name as an alias to the guardians account, if more than one child each entry will be treated uniquely for mentions and event availabillty responses. + - "Players" User Group DM will be disabled/hidden and cannot be added to Multi-Group DMs + - The event modal with Availabilty requested for the "players" user group, will have a new drop down select list (under the response buttons, above the note input box - hidden/disabled by default) and will only be unhidden/enable if the event includes the "players" user group, and only displayed for user who have a saved child list of at least one. The select list options will include the guardians child list, AND will also the guardian's name IF multiple user groups are selected for the event, with one be players, and at least one being a user group that the guardian is a member of that is not the players group. There will be a default option of "ALL", if selected, the response and note (if entered) will be the same for all "people" listed in the select list, but responses will be listed indivually in the response list for the event. + + Scenario 1: Event: party, track availability enabled, groups selected: players + parents + - select drop down will display option: "All" default selected, then guadians name on row 2 (parent user group), each child's name on susequent rows (listed in the players user group owned by the guardian) + - selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each + - selecting an idividual name will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list + Scenario 2: Event, track availability enabled, groups selected: players + - select drop down will display "All" on the top row, each child's name (listed in the players user group and owned by the guardian) + - selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each + - selecting an idividual name in the selectlist will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list + Scenario 3: Event, track availability enabled, groups selected: parents + - select drop down is hidden + - availability response and note is for the guardian only + +Option: "Mixed Age" +Descriptions: "Parents, or user managers, are required to add the minor aged child's user account to the guardians user profile. Minor aged users cannot login until this is complete." + - User manager DOB is required for all users + - Minor aged user's account are automatically suspended + on the add child form: + - search user input field, will display only a list of minor aged users. Selecting a user fills out the "Add Child" read-only form + - "Add Child" Form all fields are read-only: Firstname, Lastname, DOB*, user's avatar, number (input box), Add button when form is filled by selecting a user, Save button (disabled until there is a child added to guardian's child list). Requires admin approval. A message is sent to all users with the managers from from the default admin user account indicating "User Manager requires approval". + - The minor user account name that requires gaurdina name approval is bold red in the user manager list. When the "editing" user account, the Gaurdian field will be highlighted in red. Besides the label will be two link options [approve - green] [deny - red] (same size font as the label). Approve will clear the approval required flag and unsuspend the user account, with the guardian name save to the user's profile. + - Saving the form will update the minor aged user account with a guardian name, requires a user with manager role approval, once approved, the minor's user account is unsuspended. + - Events are handled like they are currently + - Guardians are not part of players user group DMs + - Guardians do not respond to availability on the childs behalf (new select list on the event form remains hidden/diabled) + - any private messages initiated by a user 18 years or older to a minor aged will automatically include the guardian user account as well. + -> a modal confirmation will be provide to the 18+ user that they are messaging a user that will also include their parent/guardian. + +Option: All Ages (default selection) + - "Add Child" hidden/disbled Profile select option list. + - Aliase select list is hidden/disabled + - Events are handled like they are currently + +Yellow warning symbol and text below: "This setting can only be set/changed when the user table in the database is empty." (form is read-only unless user manager is empty OR only has users with the admin role). +- if the setting is changed and users exisit with admin role, on each subsequent login the user's profile modal popup until the Date of Birth is entered. diff --git a/backend/package.json b/backend/package.json index a8df2e7..1514373 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.42", + "version": "0.12.43", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/migrations/015_minor_age_protection.sql b/backend/src/models/migrations/015_minor_age_protection.sql new file mode 100644 index 0000000..569fe67 --- /dev/null +++ b/backend/src/models/migrations/015_minor_age_protection.sql @@ -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); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index cdfd8e5..a913e62 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -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 diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 88c4f93..b147c54 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -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 }); } }); diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index e7e9583..68085bf 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -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 { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 3a980ec..c398b33 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -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; diff --git a/build.sh b/build.sh index 11d8706..23b5a62 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.42}" +VERSION="${1:-0.12.43}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index a517550..e5ec80d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.42", + "version": "0.12.43", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx index 1983316..69a4da2 100644 --- a/frontend/src/components/NewChatModal.jsx +++ b/frontend/src/components/NewChatModal.jsx @@ -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 = {} }) { + + {guardianConfirm && ( +
+
+

Guardian Added

+

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

+
+ +
+
+
+ )} ); } diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index ef67f6a..6d94fd9 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -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 }) { - {/* Tabs — select on mobile, buttons on desktop */} - {isMobile ? ( - { setTab(e.target.value); setPushResult(null); }}> + {showAddChild && } - ) : ( -
- - - - -
- )} + {tab === 'profile' && (
@@ -206,6 +276,19 @@ export default function ProfileModal({ onClose }) { style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} /> Allow others to send me direct messages + {/* Date of Birth + Phone — visible in Guardian Only / Mixed Age modes */} + {loginType !== 'all_ages' && ( +
+
+ + setDob(e.target.value)} autoComplete="off" /> +
+
+ + setPhone(e.target.value)} autoComplete="tel" /> +
+
+ )} @@ -355,6 +438,122 @@ export default function ProfileModal({ onClose }) {
)} + {tab === 'add-child' && ( +
+ {/* Existing saved aliases */} + {aliases.length > 0 && ( +
+
Saved Children
+
+ {aliases.map((a, i) => ( +
+ {a.first_name} {a.last_name} + {a.date_of_birth && {a.date_of_birth.slice(0,10)}} + +
+ ))} +
+
+ )} + + {loginType === 'guardian_only' ? ( + <> +
Add a Child
+
+
+ + setChildForm(p=>({...p,firstName:e.target.value}))} autoComplete="off" autoCapitalize="words" /> +
+
+ + setChildForm(p=>({...p,lastName:e.target.value}))} autoComplete="off" autoCapitalize="words" /> +
+
+ + setChildForm(p=>({...p,dob:e.target.value}))} autoComplete="off" /> +
+
+ + setChildForm(p=>({...p,phone:e.target.value}))} autoComplete="off" /> +
+
+ + setChildForm(p=>({...p,email:e.target.value}))} autoComplete="off" /> +
+
+ + setChildForm(p=>({...p,avatarFile:e.target.files?.[0]||null}))} /> +
+
+ + {childList.length > 0 && ( +
+ {childList.map((c, i) => ( +
+ {c.firstName} {c.lastName} + Pending save + +
+ ))} +
+ )} + + ) : loginType === 'mixed_age' ? ( + <> +
Link a Child Account
+

Search for a minor user account to link to your guardian profile. The link requires manager approval.

+ setMinorSearch(e.target.value)} autoComplete="off" /> + {minorResults.length > 0 && ( +
+ {minorResults.map(u => ( +
{ setSelectedMinor(u); setMinorSearch(''); setMinorResults([]); }}> + {u.first_name} {u.last_name} + {u.date_of_birth && {u.date_of_birth.slice(0,10)}} +
+ ))} +
+ )} + {selectedMinor && ( +
+
{selectedMinor.first_name} {selectedMinor.last_name}
+ {selectedMinor.date_of_birth &&
{selectedMinor.date_of_birth.slice(0,10)}
} +
+ + +
+
+ )} + {childList.length > 0 && ( +
+ {childList.map((c, i) => ( +
+ {c.first_name || c.firstName} {c.last_name || c.lastName} + Pending approval + +
+ ))} +
+ )} + + ) : null} + + +
+ )} + {tab === 'appearance' && (
diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 5a9dcc8..569f4a3 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -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:') + 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 ))}
+ {/* Guardian Only: responder select — shown when event targets the players group and user has aliases */} + {showResponderSelect && ( +
+ + +
+ )}
0&&(
{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( -
+
toggleNote(r.user_id):undefined} + onClick={hasNote?()=>toggleNote(rowKey):undefined} > - {r.display_name||r.name} + {displayName} + {r.is_alias&&child} {hasNote&&( )} diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index 7279a4e..b79f0b6 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -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 ( +
+
Login Type
+ + {/* Warning */} +
+ ⚠️ +

+ This setting can only be set or changed when the user table is empty (no non-admin users exist). +

+
+ + {/* Options */} +
+ {LOGIN_TYPE_OPTIONS.map((opt, i) => ( + + ))} +
+ + {/* Group selectors — only shown for Guardian Only / Mixed Age */} + {needsGroups && ( +
+
+ +

The user group that children / aliases are added to.

+ +
+
+ +

Members of this group see the "Add Child" option in their profile.

+ +
+
+ )} + + +
+ ); +} + // ── 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 (
e.target === e.currentTarget && onClose()}>
@@ -311,17 +420,20 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
- {/* Tab buttons */} -
- {tabs.map(t => ( - - ))} + {/* Select navigation */} +
+ +
{tab === 'messages' && } {tab === 'team' && } + {tab === 'login-type' && } {tab === 'registration' && }
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 7220227..76483ef 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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; diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 28edde0..b0adb27 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -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 }) => { diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 7c321cd..3dc4641 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -89,10 +89,11 @@ function UserRow({ u, onUpdated, onEdit }) {
- {u.display_name || u.name} + {u.display_name || u.name} {u.display_name && ({u.name})} {u.role} {u.status !== 'active' && {u.status}} + {!!u.guardian_approval_required && Pending Guardian Approval} {!!u.is_default_admin && Default Admin}
{u.email}
@@ -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
- {/* Row 4: DOB + Guardian */} -
-
- {lbl('Date of Birth', false, '(optional)')} - 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' && ( +
+
+ {lbl('Date of Birth', loginType === 'mixed_age', loginType === 'guardian_only' ? '(optional)' : undefined)} + setDob(e.target.value)} + autoComplete="off" onFocus={onIF} onBlur={onIB} /> +
+ {loginType === 'mixed_age' && isEdit && ( +
+ {lbl('Guardian', false, '(optional)')} +
+ +
+ {user?.guardian_approval_required && ( +
+ Pending approval + + +
+ )} +
+ )}
-
- {lbl('Guardian', false, '(optional)')} - -
-
+ )} {/* 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} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 679fec2..e3a4488 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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) => { From 1a85d3930ee112d12503622d1aacd73a56e0a996 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Mon, 30 Mar 2026 16:32:21 -0400 Subject: [PATCH 02/20] v0.12.44 message notification updates --- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ProfileModal.jsx | 8 +++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/package.json b/backend/package.json index 1514373..aa93e07 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.43", + "version": "0.12.44", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 23b5a62..030c228 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.43}" +VERSION="${1:-0.12.44}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index e5ec80d..214055d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.43", + "version": "0.12.44", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 6d94fd9..6dc5a6b 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -30,6 +30,8 @@ export default function ProfileModal({ onClose }) { typeof Notification !== 'undefined' ? Notification.permission : 'unsupported' ); const isIOS = /iphone|ipad/i.test(navigator.userAgent); + const isAndroid = /android/i.test(navigator.userAgent); + const isDesktop = !isIOS && !isAndroid; const isStandalone = window.navigator.standalone === true; const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); @@ -297,7 +299,11 @@ export default function ProfileModal({ onClose }) { {tab === 'notifications' && (
- {isIOS && !isStandalone ? ( + {isDesktop ? ( +
+ In-app notifications are active on this device. Unread message counts and browser tab indicators update in real time — no additional setup needed. +
+ ) : isIOS && !isStandalone ? (
Home Screen required for notifications
From d0f10c4d7ea405003a3fd8a0228a18a8c9c9b6fc Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Mon, 30 Mar 2026 19:07:15 -0400 Subject: [PATCH 03/20] v0.12.45 fixed Guardian only feature --- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/ProfileModal.jsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/package.json b/backend/package.json index aa93e07..84789f6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.44", + "version": "0.12.45", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 030c228..9731cf8 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.44}" +VERSION="${1:-0.12.45}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 214055d..9858803 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.44", + "version": "0.12.45", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 6dc5a6b..7b74658 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -64,13 +64,13 @@ export default function ProfileModal({ onClose }) { // Load login type + check if user is in guardians group useEffect(() => { - Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { groups }]) => { + Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { userGroups }]) => { 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); + const inGroup = (userGroups || []).some(g => g.id === gid); setShowAddChild(inGroup); } }).catch(() => {}); From 350bb25ecdd278ed747fd5ea9de168bd8cdeb621 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 31 Mar 2026 12:21:59 -0400 Subject: [PATCH 04/20] v0.12.46 host bug fixes and password reset feature, --- backend/package.json | 2 +- backend/src/models/db.js | 36 +++++++++++-- backend/src/routes/auth.js | 2 +- backend/src/routes/host.js | 12 ++++- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/HostPanel.jsx | 42 +++++++++++++++ frontend/src/components/ProfileModal.jsx | 5 +- frontend/src/pages/UserManagerPage.jsx | 69 ++++++++++++------------ 9 files changed, 127 insertions(+), 45 deletions(-) diff --git a/backend/package.json b/backend/package.json index 84789f6..cb0135e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.45", + "version": "0.12.46", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/models/db.js b/backend/src/models/db.js index b019b97..0820776 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -249,7 +249,21 @@ async function seedUserGroups(schema) { const existing = await queryOne(schema, 'SELECT id FROM user_groups WHERE name = $1', [name] ); - if (existing) continue; + if (existing) { + // Auto-configure feature settings if not already set + if (name === 'Players') { + await exec(schema, + "INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING", + [existing.id.toString()] + ); + } else if (name === 'Parents') { + await exec(schema, + "INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING", + [existing.id.toString()] + ); + } + continue; + } // Create the managed DM chat group first const gr = await queryResult(schema, @@ -259,17 +273,31 @@ async function seedUserGroups(schema) { const dmGroupId = gr.rows[0].id; // Create the user group linked to the DM group - await exec(schema, - 'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING', + const ugr = await queryResult(schema, + 'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id', [name, dmGroupId] ); + const ugId = ugr.rows[0]?.id; console.log(`[DB:${schema}] Default user group created: ${name}`); + + // Auto-configure feature settings for players/parents groups + if (ugId && name === 'Players') { + await exec(schema, + "INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING", + [ugId.toString()] + ); + } else if (ugId && name === 'Parents') { + await exec(schema, + "INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING", + [ugId.toString()] + ); + } } } async function seedAdmin(schema) { const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim(); - const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local'; + const adminEmail = (strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local').toLowerCase(); const adminName = strip(process.env.ADMIN_NAME) || 'Admin User'; const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234'; const pwReset = process.env.ADMPW_RESET === 'true'; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3b68131..b78f252 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -12,7 +12,7 @@ module.exports = function(io) { router.post('/login', async (req, res) => { const { email, password, rememberMe } = req.body; try { - const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]); + const user = await queryOne(req.schema, 'SELECT * FROM users WHERE LOWER(email) = LOWER($1)', [email]); if (!user) return res.status(401).json({ error: 'Invalid credentials' }); if (user.status === 'suspended') { diff --git a/backend/src/routes/host.js b/backend/src/routes/host.js index cddf623..a21fc72 100644 --- a/backend/src/routes/host.js +++ b/backend/src/routes/host.js @@ -9,6 +9,7 @@ */ const express = require('express'); +const bcrypt = require('bcryptjs'); const router = express.Router(); const { query, queryOne, queryResult, exec, @@ -186,7 +187,7 @@ router.post('/tenants', async (req, res) => { // Supports updating: name, plan, customDomain, status router.patch('/tenants/:slug', async (req, res) => { - const { name, plan, customDomain, status } = req.body; + const { name, plan, customDomain, status, adminPassword } = req.body; try { const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug = $1', [req.params.slug] @@ -224,6 +225,15 @@ router.patch('/tenants/:slug', async (req, res) => { await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]); } + // Reset tenant admin password if provided + if (adminPassword && adminPassword.length >= 6) { + const hash = bcrypt.hashSync(adminPassword, 10); + await exec(tenant.schema_name, + "UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE", + [hash] + ); + } + await reloadTenantCache(); const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]); res.json({ tenant: updated }); diff --git a/build.sh b/build.sh index 9731cf8..b705232 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.45}" +VERSION="${1:-0.12.46}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 9858803..9779498 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.45", + "version": "0.12.46", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/HostPanel.jsx b/frontend/src/components/HostPanel.jsx index bf1f5a6..b9a7800 100644 --- a/frontend/src/components/HostPanel.jsx +++ b/frontend/src/components/HostPanel.jsx @@ -164,21 +164,28 @@ function ProvisionModal({ api, baseDomain, onClose, onDone, toast }) { function EditModal({ api, tenant, onClose, onDone }) { const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' }); + const [adminPassword, setAdminPassword] = useState(''); + const [showAdminPass, setShowAdminPass] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const set = k => v => setForm(f => ({ ...f, [k]: v })); const handle = async () => { + if (adminPassword && adminPassword.length < 6) + return setError('Admin password must be at least 6 characters'); setSaving(true); setError(''); try { const { tenant: updated } = await api.updateTenant(tenant.slug, { name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null, + ...(adminPassword ? { adminPassword } : {}), }); onDone(updated); } catch (e) { setError(e.message); } finally { setSaving(false); } }; + const adminEmail = tenant.admin_email || '(uses system default from .env)'; + return (
e.target === e.currentTarget && onClose()}>
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) { +
+
Admin Account
+ + + +
+ +
+ setAdminPassword(e.target.value)} + placeholder="Leave blank to keep current password" + autoComplete="new-password" + className="input" + style={{ fontSize:13, paddingRight:40 }} + /> + +
+ Admin will be required to change password on next login +
+
+
diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 7b74658..d775c8b 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -69,7 +69,10 @@ export default function ProfileModal({ onClose }) { const gid = parseInt(s.feature_guardians_group_id); setLoginType(lt); setGuardiansGroupId(gid || null); - if (lt !== 'all_ages' && gid) { + if (lt === 'guardian_only') { + // In guardian_only mode all authenticated users are guardians — always show Add Child + setShowAddChild(true); + } else if (lt === 'mixed_age' && gid) { const inGroup = (userGroups || []).some(g => g.id === gid); setShowAddChild(inGroup); } diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 3dc4641..bad6a1e 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -283,42 +283,41 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD
- {/* Row 4: DOB + Guardian — visible when loginType is not 'all_ages' */} - {loginType !== 'all_ages' && ( -
-
- {lbl('Date of Birth', loginType === 'mixed_age', loginType === 'guardian_only' ? '(optional)' : undefined)} - setDob(e.target.value)} - autoComplete="off" onFocus={onIF} onBlur={onIB} /> -
- {loginType === 'mixed_age' && isEdit && ( -
- {lbl('Guardian', false, '(optional)')} -
- -
- {user?.guardian_approval_required && ( -
- Pending approval - - -
- )} -
- )} + {/* Row 4: DOB + Guardian */} +
+
+ {lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(optional)' : undefined)} + setDob(e.target.value)} + autoComplete="off" onFocus={onIF} onBlur={onIB} />
- )} + {/* Guardian field — shown for all login types except guardian_only (children are aliases there, not users) */} + {loginType !== 'guardian_only' && ( +
+ {lbl('Guardian', false, '(optional)')} +
+ +
+ {isEdit && user?.guardian_approval_required && ( +
+ Pending approval + + +
+ )} +
+ )} +
{/* Row 4b: User Groups */} {allUserGroups?.length > 0 && ( From 9c263e7e8df8b0da7d68a5b0aa3ba4aea416c742 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 31 Mar 2026 13:51:47 -0400 Subject: [PATCH 05/20] v0.12.47 Add Child alias update --- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- .../src/components/AddChildAliasModal.jsx | 195 +++++++++++++++++ frontend/src/components/NavDrawer.jsx | 14 +- frontend/src/components/ProfileModal.jsx | 200 +----------------- frontend/src/pages/Chat.jsx | 101 ++++++--- 7 files changed, 288 insertions(+), 228 deletions(-) create mode 100644 frontend/src/components/AddChildAliasModal.jsx diff --git a/backend/package.json b/backend/package.json index cb0135e..a0fa40d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.46", + "version": "0.12.47", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index b705232..c756507 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.46}" +VERSION="${1:-0.12.47}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index 9779498..bc2f70c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.46", + "version": "0.12.47", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx new file mode 100644 index 0000000..543e595 --- /dev/null +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -0,0 +1,195 @@ +import { useState, useEffect } from 'react'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { api } from '../utils/api.js'; + +export default function AddChildAliasModal({ onClose }) { + const toast = useToast(); + const [aliases, setAliases] = useState([]); + const [editingAlias, setEditingAlias] = useState(null); // null = new entry + const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); + const [avatarFile, setAvatarFile] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {}); + }, []); + + const set = k => e => setForm(p => ({ ...p, [k]: e.target.value })); + + const resetForm = () => { + setEditingAlias(null); + setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' }); + setAvatarFile(null); + }; + + const handleSelectAlias = (a) => { + if (editingAlias?.id === a.id) { resetForm(); return; } + setEditingAlias(a); + setForm({ + firstName: a.first_name || '', + lastName: a.last_name || '', + dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '', + phone: a.phone || '', + email: a.email || '', + }); + setAvatarFile(null); + }; + + const handleSave = async () => { + if (!form.firstName.trim() || !form.lastName.trim()) + return toast('First and last name required', 'error'); + setSaving(true); + try { + if (editingAlias) { + await api.updateAlias(editingAlias.id, { + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + dateOfBirth: form.dob || null, + phone: form.phone || null, + email: form.email || null, + }); + if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile); + toast('Child alias updated', 'success'); + } else { + const { alias } = await api.createAlias({ + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + dateOfBirth: form.dob || null, + phone: form.phone || null, + email: form.email || null, + }); + if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile); + toast('Child alias added', 'success'); + } + const { aliases: fresh } = await api.getAliases(); + setAliases(fresh || []); + resetForm(); + } catch (e) { + toast(e.message, 'error'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (e, aliasId) => { + e.stopPropagation(); + try { + await api.deleteAlias(aliasId); + setAliases(prev => prev.filter(a => a.id !== aliasId)); + if (editingAlias?.id === aliasId) resetForm(); + toast('Child alias removed', 'success'); + } catch (err) { toast(err.message, 'error'); } + }; + + const lbl = (text, required) => ( + + ); + + return ( +
e.target === e.currentTarget && onClose()}> +
+ + {/* Header */} +
+

Add Child Alias

+ +
+ + {/* 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}` + : 'New Child Alias'} +
+ + {/* 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/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index 4fc6f4b..856ca2b 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -13,13 +13,14 @@ const NAV_ICON = { settings: , }; -export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) { +export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, onAddChild, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) { const { user } = useAuth(); const drawerRef = useRef(null); const isAdmin = user?.role === 'admin'; const userGroupIds = features.userGroupMemberships || []; const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid)); const hasUserGroups = userGroupIds.length > 0; + const showAddChild = features.loginType === 'guardian_only' && features.inGuardiansGroup; useEffect(() => { if (!open) return; @@ -80,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, )} {/* Tools section */} - {canAccessTools && ( + {(canAccessTools || showAddChild) && ( <>
Tools
- {item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })} - {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })} + {canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })} + {canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })} + {showAddChild && item( + , + 'Add Child Aliase', + onAddChild + )} )}
diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index d775c8b..8d83dd6 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -23,7 +23,7 @@ export default function ProfileModal({ onClose }) { const [newPw, setNewPw] = useState(''); const [confirmPw, setConfirmPw] = useState(''); const [loading, setLoading] = useState(false); - const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' | 'add-child' + const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' const [pushTesting, setPushTesting] = useState(false); const [pushResult, setPushResult] = useState(null); const [notifPermission, setNotifPermission] = useState( @@ -36,20 +36,8 @@ 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); + // Minor age protection — DOB/phone display only + const [loginType, setLoginType] = useState('all_ages'); const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY)); const [fontScale, setFontScale] = useState( @@ -62,32 +50,13 @@ export default function ProfileModal({ onClose }) { return () => window.removeEventListener('resize', onResize); }, []); - // Load login type + check if user is in guardians group + // Load login type for DOB/phone field visibility useEffect(() => { - Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { userGroups }]) => { - const lt = s.feature_login_type || 'all_ages'; - const gid = parseInt(s.feature_guardians_group_id); - setLoginType(lt); - setGuardiansGroupId(gid || null); - if (lt === 'guardian_only') { - // In guardian_only mode all authenticated users are guardians — always show Add Child - setShowAddChild(true); - } else if (lt === 'mixed_age' && gid) { - const inGroup = (userGroups || []).some(g => g.id === gid); - setShowAddChild(inGroup); - } + api.getSettings().then(({ settings: s }) => { + setLoginType(s.feature_login_type || 'all_ages'); }).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); @@ -109,46 +78,6 @@ 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; @@ -219,7 +148,6 @@ export default function ProfileModal({ onClose }) { - {showAddChild && }
@@ -447,122 +375,6 @@ export default function ProfileModal({ onClose }) {
)} - {tab === 'add-child' && ( -
- {/* Existing saved aliases */} - {aliases.length > 0 && ( -
-
Saved Children
-
- {aliases.map((a, i) => ( -
- {a.first_name} {a.last_name} - {a.date_of_birth && {a.date_of_birth.slice(0,10)}} - -
- ))} -
-
- )} - - {loginType === 'guardian_only' ? ( - <> -
Add a Child
-
-
- - setChildForm(p=>({...p,firstName:e.target.value}))} autoComplete="off" autoCapitalize="words" /> -
-
- - setChildForm(p=>({...p,lastName:e.target.value}))} autoComplete="off" autoCapitalize="words" /> -
-
- - setChildForm(p=>({...p,dob:e.target.value}))} autoComplete="off" /> -
-
- - setChildForm(p=>({...p,phone:e.target.value}))} autoComplete="off" /> -
-
- - setChildForm(p=>({...p,email:e.target.value}))} autoComplete="off" /> -
-
- - setChildForm(p=>({...p,avatarFile:e.target.files?.[0]||null}))} /> -
-
- - {childList.length > 0 && ( -
- {childList.map((c, i) => ( -
- {c.firstName} {c.lastName} - Pending save - -
- ))} -
- )} - - ) : loginType === 'mixed_age' ? ( - <> -
Link a Child Account
-

Search for a minor user account to link to your guardian profile. The link requires manager approval.

- setMinorSearch(e.target.value)} autoComplete="off" /> - {minorResults.length > 0 && ( -
- {minorResults.map(u => ( -
{ setSelectedMinor(u); setMinorSearch(''); setMinorResults([]); }}> - {u.first_name} {u.last_name} - {u.date_of_birth && {u.date_of_birth.slice(0,10)}} -
- ))} -
- )} - {selectedMinor && ( -
-
{selectedMinor.first_name} {selectedMinor.last_name}
- {selectedMinor.date_of_birth &&
{selectedMinor.date_of_birth.slice(0,10)}
} -
- - -
-
- )} - {childList.length > 0 && ( -
- {childList.map((c, i) => ( -
- {c.first_name || c.firstName} {c.last_name || c.lastName} - Pending approval - -
- ))} -
- )} - - ) : null} - - -
- )} - {tab === 'appearance' && (
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index b0adb27..176faf2 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -16,6 +16,7 @@ import GlobalBar from '../components/GlobalBar.jsx'; import AboutModal from '../components/AboutModal.jsx'; import HelpModal from '../components/HelpModal.jsx'; import NavDrawer from '../components/NavDrawer.jsx'; +import AddChildAliasModal from '../components/AddChildAliasModal.jsx'; import SchedulePage from '../components/SchedulePage.jsx'; import MobileGroupManager from '../components/MobileGroupManager.jsx'; import './Chat.css'; @@ -48,6 +49,9 @@ export default function Chat() { const [drawerOpen, setDrawerOpen] = useState(false); const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true }); const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded + const [addChildPending, setAddChildPending] = useState(false); // defer add-child popup until help closes + const addChildCheckedRef = useRef(false); // only auto-check aliases once per session + const modalRef = useRef(null); // always reflects current modal value in async callbacks const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [showSidebar, setShowSidebar] = useState(true); @@ -80,28 +84,31 @@ export default function Chat() { // Keep groupsRef in sync so visibility/reconnect handlers can read current groups useEffect(() => { groupsRef.current = groups; }, [groups]); - // Load feature flags + current user's group memberships on mount + // Load feature flags + current user's group memberships on mount (combined for consistent inGuardiansGroup) const loadFeatures = useCallback(() => { - api.getSettings().then(({ settings }) => { - setFeatures(prev => ({ - ...prev, - branding: settings.feature_branding === 'true', - groupManager: settings.feature_group_manager === 'true', - scheduleManager: settings.feature_schedule_manager === 'true', - appType: settings.app_type || 'RosterChirp-Chat', - teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'), - isHostDomain: settings.is_host_domain === 'true', - msgPublic: settings.feature_msg_public !== 'false', - 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 }) => { - setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) })); - }).catch(() => {}); + Promise.all([api.getSettings(), api.getMyUserGroups()]) + .then(([{ settings: s }, { userGroups }]) => { + const memberships = (userGroups || []).map(g => g.id); + const guardiansGroupId = s.feature_guardians_group_id ? parseInt(s.feature_guardians_group_id) : null; + setFeatures(prev => ({ + ...prev, + branding: s.feature_branding === 'true', + groupManager: s.feature_group_manager === 'true', + scheduleManager: s.feature_schedule_manager === 'true', + appType: s.app_type || 'RosterChirp-Chat', + teamToolManagers: JSON.parse(s.team_tool_managers || s.team_group_managers || '[]'), + isHostDomain: s.is_host_domain === 'true', + msgPublic: s.feature_msg_public !== 'false', + msgGroup: s.feature_msg_group !== 'false', + msgPrivateGroup: s.feature_msg_private_group !== 'false', + msgU2U: s.feature_msg_u2u !== 'false', + loginType: s.feature_login_type || 'all_ages', + playersGroupId: s.feature_players_group_id ? parseInt(s.feature_players_group_id) : null, + guardiansGroupId, + userGroupMemberships: memberships, + inGuardiansGroup: guardiansGroupId ? memberships.includes(guardiansGroupId) : false, + })); + }).catch(() => {}); }, []); useEffect(() => { @@ -110,6 +117,35 @@ export default function Chat() { return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures); }, [loadFeatures]); + // 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 + useEffect(() => { + if (addChildCheckedRef.current) return; + if (features.loginType !== 'guardian_only' || !features.inGuardiansGroup) return; + addChildCheckedRef.current = true; + api.getAliases().then(({ aliases }) => { + if (!(aliases || []).length) { + if (modalRef.current === 'help') { + setAddChildPending(true); // defer until help closes + } else if (!modalRef.current) { + setModal('addchild'); + } + } + }).catch(() => {}); + }, [features.loginType, features.inGuardiansGroup]); + + // Close help — open deferred add-child popup if pending + const handleHelpClose = useCallback(() => { + if (addChildPending) { + setAddChildPending(false); + setModal('addchild'); + } else { + setModal(null); + } + }, [addChildPending]); + // Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS useEffect(() => { if (!('serviceWorker' in navigator)) return; @@ -601,12 +637,14 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />}
@@ -630,12 +668,14 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />}
@@ -689,12 +729,14 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />} {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />} @@ -721,6 +763,7 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} @@ -728,7 +771,8 @@ export default function Chat() { {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'branding' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />} {modal === 'about' && setModal(null)} />}
@@ -760,6 +804,7 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} @@ -774,7 +819,8 @@ export default function Chat() {
)} {modal === 'about' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />}
); @@ -842,7 +888,8 @@ export default function Chat() { {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'about' && setModal(null)} />} - {modal === 'help' && setModal(null)} dismissed={helpDismissed} />} + {modal === 'help' && } + {modal === 'addchild' && setModal(null)} />}
); } From f942bc45b995648d519d3ff2bcace9e6ce37f3bf Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 31 Mar 2026 14:12:51 -0400 Subject: [PATCH 06/20] bug fixes --- frontend/src/components/NavDrawer.jsx | 2 +- frontend/src/pages/Chat.jsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index 856ca2b..f61a31e 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -86,7 +86,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
Tools
{canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })} {canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })} - {showAddChild && item( + {showAddChild && onAddChild && item( , 'Add Child Aliase', onAddChild diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 176faf2..edd33df 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -878,6 +878,7 @@ export default function Chat() { onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} + onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }} features={features} currentPage={page} isMobile={isMobile} From a3a878854e0cc9975a02aab6474c2f9289536b08 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Tue, 31 Mar 2026 20:11:40 -0400 Subject: [PATCH 07/20] v0.12.48 Login Type bug fixes --- backend/package.json | 2 +- backend/src/routes/usergroups.js | 11 ++++++++++- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/pages/GroupManagerPage.jsx | 23 +++++++++++++++++++---- frontend/src/pages/UserManagerPage.jsx | 11 +++++++++-- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/backend/package.json b/backend/package.json index a0fa40d..f731fb9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.47", + "version": "0.12.48", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js index 63d0cdf..cd49d19 100644 --- a/backend/src/routes/usergroups.js +++ b/backend/src/routes/usergroups.js @@ -211,7 +211,16 @@ router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id WHERE ugm.user_group_id=$1 ORDER BY u.name ASC `, [req.params.id]); - res.json({ group, members }); + const aliasMembers = await query(req.schema, ` + SELECT ga.id, ga.first_name, ga.last_name, + ga.first_name || ' ' || ga.last_name AS name, + ga.guardian_id, ga.avatar, ga.date_of_birth + FROM alias_group_members agm + JOIN guardian_aliases ga ON ga.id = agm.alias_id + WHERE agm.user_group_id=$1 + ORDER BY ga.first_name, ga.last_name ASC + `, [req.params.id]); + res.json({ group, members, aliasMembers }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/build.sh b/build.sh index c756507..d4f919d 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.47}" +VERSION="${1:-0.12.48}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index bc2f70c..37e25c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.47", + "version": "0.12.48", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx index 5e14088..24ba31f 100644 --- a/frontend/src/pages/GroupManagerPage.jsx +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -67,6 +67,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { const [savedMembers, setSavedMembers] = useState(new Set()); const [members, setMembers] = useState(new Set()); const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted + const [aliasMembers, setAliasMembers] = useState([]); // child aliases in this group const [editName, setEditName] = useState(''); const [noDm, setNoDm] = useState(false); const [saving, setSaving] = useState(false); @@ -81,16 +82,17 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { const selectGroup = async (g) => { setShowDelete(false); setAccordionOpen(false); - const { members: mems } = await api.getUserGroup(g.id); + const { members: mems, aliasMembers: aliases } = await api.getUserGroup(g.id); const ids = new Set(mems.map(m => m.id)); setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids); setFullMembers(mems); + setAliasMembers(aliases || []); // No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked setNoDm(!g.dm_group_id); }; const clearSelection = () => { setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); - setShowDelete(false); setFullMembers([]); setNoDm(false); + setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false); }; const handleSave = async () => { @@ -102,9 +104,9 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { const createDm = !selected.dm_group_id && !noDm; const { group: updated } = await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members], createDm }); toast('Group updated', 'success'); - const { members: fresh } = await api.getUserGroup(selected.id); + const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id); const freshIds = new Set(fresh.map(m => m.id)); - setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); + setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setAliasMembers(freshAliases || []); // Reflect new dm_group_id if a DM was just created setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id })); if (createDm) setNoDm(false); @@ -220,6 +222,19 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {

{members.size} selected

+ {aliasMembers.length > 0 && ( +
+ +
+ {aliasMembers.map((a, i) => ( +
+ {a.name} + {a.date_of_birth && {a.date_of_birth.slice(0,10)}} +
+ ))} +
+
+ )} {deletedMembers.length > 0 && (
); } 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}`), From 18e4a922414fa652a407d3566c5977f736be97f6 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 15:22:38 -0400 Subject: [PATCH 13/20] minor bug fixes --- backend/src/routes/users.js | 17 +++----- frontend/src/components/ProfileModal.jsx | 53 ++++++++++++++++++++++- frontend/src/components/SettingsModal.jsx | 4 +- frontend/src/pages/UserManagerPage.jsx | 34 ++++++++------- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 3dc6331..5abf4be 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -123,9 +123,6 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { 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 @@ -164,10 +161,6 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => 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') @@ -235,12 +228,16 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) => const newRole = validRoles.includes(u.role) ? u.role : 'member'; const fn = firstName || name.split(' ')[0] || ''; const ln = lastName || name.split(' ').slice(1).join(' ') || ''; + const dob = (u.dateOfBirth || u.dob || '').trim() || null; + const isMinor = isMinorFromDOB(dob); + const loginType = await getLoginType(req.schema); + const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active'; const r = await queryResult(req.schema, - "INSERT INTO users (name,first_name,last_name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,'active',TRUE) RETURNING id", - [resolvedName, fn, ln, email, hash, newRole] + "INSERT INTO users (name,first_name,last_name,email,password,role,date_of_birth,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) RETURNING id", + [resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus] ); const userId = r.rows[0].id; - await addUserToPublicGroups(req.schema, userId); + if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId); if (newRole === '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]); diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx index 8d83dd6..a92a6f8 100644 --- a/frontend/src/components/ProfileModal.jsx +++ b/frontend/src/components/ProfileModal.jsx @@ -36,8 +36,10 @@ export default function ProfileModal({ onClose }) { const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); - // Minor age protection — DOB/phone display only + // Minor age protection — DOB/phone display + mixed_age forced-DOB gate const [loginType, setLoginType] = useState('all_ages'); + // True when mixed_age mode and the user still has no DOB on record + const needsDob = loginType === 'mixed_age' && !user?.date_of_birth; const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY)); const [fontScale, setFontScale] = useState( @@ -105,6 +107,55 @@ export default function ProfileModal({ onClose }) { } }; + // ── Forced DOB gate for mixed_age users ─────────────────────────────────── + if (needsDob) { + return ( +
+
+

Date of Birth Required

+

+ Your organisation requires a date of birth on file. Please enter yours to continue. +

+
+ + setDob(e.target.value)} + autoComplete="off" + style={{ borderColor: dob ? undefined : 'var(--error)' }} + /> +
+ +
+
+ ); + } + return (
e.target === e.currentTarget && onClose()}>
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index b79f0b6..fdc66fe 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -162,7 +162,7 @@ function TeamManagementTab() { const LOGIN_TYPE_OPTIONS = [ { id: 'all_ages', - label: 'All Ages', + label: 'Unrestricted', desc: 'No age restrictions. All users interact normally. Default behaviour.', }, { @@ -172,7 +172,7 @@ const LOGIN_TYPE_OPTIONS = [ }, { id: 'mixed_age', - label: 'Mixed Age', + label: 'Restricted', 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.", }, ]; diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index c8ee8e2..f433a9a 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -14,12 +14,13 @@ function isValidPhone(p) { return /^\d{7,15}$/.test(digits); } -// Format: email,firstname,lastname,password,role,usergroup (exactly 5 commas / 6 fields) -function parseCSV(text, ignoreFirstRow, allUserGroups) { +// Format: email,firstname,lastname,dob,password,role,usergroup (exactly 6 commas / 7 fields) +function parseCSV(text, ignoreFirstRow, allUserGroups, loginType) { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const rows = [], invalid = []; const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g])); const validRoles = ['member', 'manager', 'admin']; + const requireDob = loginType === 'mixed_age'; for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -27,12 +28,13 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) { if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue; const parts = line.split(','); - if (parts.length !== 6) { invalid.push({ line, reason: `Must have exactly 5 commas (has ${parts.length - 1})` }); continue; } - const [email, firstName, lastName, password, roleRaw, usergroupRaw] = parts.map(p => p.trim()); + if (parts.length !== 7) { invalid.push({ line, reason: `Must have exactly 6 commas (has ${parts.length - 1})` }); continue; } + const [email, firstName, lastName, dobRaw, password, roleRaw, usergroupRaw] = parts.map(p => p.trim()); if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; } if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; } if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; } + if (requireDob && !dobRaw) { invalid.push({ line, reason: 'Date of birth required in Restricted login type' }); continue; } const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member'; const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null; @@ -42,6 +44,7 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) { firstName, lastName, password, + dateOfBirth: dobRaw || null, role, userGroupId: matchedGroup?.id || null, userGroupName: usergroupRaw || null, @@ -165,7 +168,6 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD 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'); @@ -286,7 +288,7 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD {/* Row 4: DOB + Guardian */}
- {lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(optional)' : undefined)} + {lbl('Date of Birth', false, '(optional)')} setDob(e.target.value)} autoComplete="off" onFocus={onIF} onBlur={onIB} /> @@ -388,7 +390,7 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD } // ── Bulk Import Form ────────────────────────────────────────────────────────── -function BulkImportForm({ userPass, allUserGroups, onCreated }) { +function BulkImportForm({ userPass, allUserGroups, loginType, onCreated }) { const toast = useToast(); const fileRef = useRef(null); const [csvFile, setCsvFile] = useState(null); @@ -403,9 +405,9 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) { // Re-parse whenever raw text or options change useEffect(() => { if (!rawText) return; - const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups); + const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups, loginType); setCsvRows(rows); setCsvInvalid(invalid); - }, [rawText, ignoreFirst, allUserGroups]); + }, [rawText, ignoreFirst, allUserGroups, loginType]); const handleFile = e => { const file = e.target.files?.[0]; if (!file) return; @@ -435,11 +437,11 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) { {/* Format info box */}

CSV Format

- {'FULL: email,firstname,lastname,password,role,usergroup'} - {'MINIMUM: email,firstname,lastname,,,'} + {'FULL: email,firstname,lastname,dob,password,role,usergroup'} + {'MINIMUM: email,firstname,lastname,,,,'}

Examples:

- {'example@rosterchirp.com,Barney,Rubble,,member,parents'} - {'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'} + {'example@rosterchirp.com,Barney,Rubble,1970-11-21,,member,parents'} + {'example@rosterchirp.com,Barney,Rubble,2013-06-11,Ori0n2026!,member,players'}

Blank password defaults to {userPass}. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.

@@ -455,8 +457,8 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {

CSV Requirements

    -
  • Exactly 5 commas per row (rows with more or less will be skipped)
  • -
  • email, firstname, lastname are required
  • +
  • Exactly six (6) commas per row (rows with more or less will be skipped)
  • +
  • email, firstname, lastname are required fields{loginType === 'mixed_age' ? <> (DOB field required for Restricted login type) : ''}.
  • A user can only be added to one group during bulk import
  • Optional fields left blank will use system defaults
@@ -711,7 +713,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {/* BULK IMPORT */} {view === 'bulk' && (
- +
)}
From e4ac7e248ce3d5d42ce6c84d0f7d4b54a5dd5cd1 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 15:34:37 -0400 Subject: [PATCH 14/20] bug fix: Family manager now shows on drawer navigation menu. --- frontend/src/components/NavDrawer.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx index 963d04f..b7722da 100644 --- a/frontend/src/components/NavDrawer.jsx +++ b/frontend/src/components/NavDrawer.jsx @@ -20,7 +20,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, const userGroupIds = features.userGroupMemberships || []; const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid)); const hasUserGroups = userGroupIds.length > 0; - const showAddChild = features.loginType === 'guardian_only' && features.inGuardiansGroup; + const showAddChild = (features.loginType === 'guardian_only' || features.loginType === 'mixed_age') && features.inGuardiansGroup; useEffect(() => { if (!open) return; From ae47f66ef5472ddbcd3601de540a2dca302a2aef Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 18:15:10 -0400 Subject: [PATCH 15/20] hint messages update --- backend/src/routes/users.js | 6 +++--- .../src/components/AddChildAliasModal.jsx | 2 +- frontend/src/components/SettingsModal.jsx | 21 +++++++++++-------- frontend/src/pages/Chat.jsx | 7 +++++-- frontend/src/pages/GroupManagerPage.jsx | 2 +- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 5abf4be..4efd468 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,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' : ''}`, + `SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor,u.is_default_admin 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,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' : ''}`, + `SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor,is_default_admin 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,is_minor 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,is_default_admin FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`, [`%${q}%`] ); } diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index cb0402e..2fe6007 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -40,7 +40,7 @@ export default function AddChildAliasModal({ features = {}, onClose }) { setPartner(p); setSelectedPartnerId(p?.id?.toString() || ''); setRespondSeparately(p?.respond_separately || false); - setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id)); + setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id && !u.is_default_admin)); if (isMixedAge) { setMinorPlayers(thirdRes.users || []); } else { diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index fdc66fe..19bdf0e 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -69,7 +69,7 @@ function MessagesTab() { const rows = [ { key: 'msgPublic', label: 'Public Messages', desc: 'Public group channels visible to all members.' }, - { key: 'msgGroup', label: 'Group Messages', desc: 'Private group messages managed by User Groups.' }, + { key: 'msgGroup', label: 'User Group Messages', desc: 'Private group messages managed by User Groups.' }, { key: 'msgPrivateGroup', label: 'Private Group Messages', desc: 'Private multi-member group conversations.' }, { key: 'msgU2U', label: 'Private Messages (U2U)', desc: 'One-on-one direct messages between users.' }, ]; @@ -162,18 +162,18 @@ function TeamManagementTab() { const LOGIN_TYPE_OPTIONS = [ { id: 'all_ages', - label: 'Unrestricted', - desc: 'No age restrictions. All users interact normally. Default behaviour.', + label: 'Unrestricted (default)', + desc: 'No age restrictions. All users interact normally.', }, { 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.", + desc: "Parents/Guardians login one. Parents/Guardians are required to add their child's details in the \"Family Manager\". They will also respond on behalf of the child for events with availability tracking.", }, { id: 'mixed_age', label: 'Restricted', - 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.", + desc: "No age restriction for login. Date of Birth is a required field. Parents/Guardians must select their child in the Family Manager to allow them to login. Any private message initiated by any adult to a minor aged user will include the child's designated guardian.", }, ]; @@ -247,7 +247,7 @@ function LoginTypeTab() {
-

The user group that children / aliases are added to.

+

Select a group that minor aged users will be put in by default. *

setGuardiansGroupId(e.target.value)}> {userGroups.map(g => )}
+

+ * Open Group Manager to create a different group, if none are suitable in these lists. +

)} @@ -396,7 +399,7 @@ function RegistrationTab({ onFeaturesChanged }) { // ── Main modal ──────────────────────────────────────────────────────────────── export default function SettingsModal({ onClose, onFeaturesChanged }) { - const [tab, setTab] = useState('messages'); + const [tab, setTab] = useState('login-type'); const [appType, setAppType] = useState('RosterChirp-Chat'); useEffect(() => { @@ -424,9 +427,9 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 5804a03..20705f2 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -137,15 +137,18 @@ export default function Chat() { }).catch(() => {}); }, [features.loginType, features.inGuardiansGroup]); - // Close help — open deferred add-child popup if pending + // Close help — open deferred add-child popup if pending, or settings for first-time default admin const handleHelpClose = useCallback(() => { if (addChildPending) { setAddChildPending(false); setModal('addchild'); + } else if (!helpDismissed && user?.is_default_admin && !localStorage.getItem('rosterchirp_admin_setup_shown')) { + localStorage.setItem('rosterchirp_admin_setup_shown', '1'); + setModal('settings'); } else { setModal(null); } - }, [addChildPending]); + }, [addChildPending, helpDismissed, user]); // Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS useEffect(() => { diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx index 3f05f7a..ecd6e73 100644 --- a/frontend/src/pages/GroupManagerPage.jsx +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -759,7 +759,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, const onRefresh = () => setRefreshKey(k => k+1); useEffect(() => { - api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {}); + api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active' && !u.is_default_admin).sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}); api.getSettings().then(({ settings }) => { const pgid = (settings || []).find(s => s.key === 'feature_players_group_id')?.value; From 4df92752bb9fb2bcac6d6478412c7301b8498016 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 18:16:23 -0400 Subject: [PATCH 16/20] v0.12.52 version bump --- backend/package.json | 2 +- build.sh | 2 +- frontend/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/package.json b/backend/package.json index b8ec0ce..49cf63c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.51", + "version": "0.12.52", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 8fe248e..bf94264 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.51}" +VERSION="${1:-0.12.52}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index c353ee8..e35d978 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.51", + "version": "0.12.52", "private": true, "scripts": { "dev": "vite", From 18c63953cce8b41dd427b62f32698abf2cd74005 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Thu, 2 Apr 2026 18:40:51 -0400 Subject: [PATCH 17/20] v0.12.53 Restricted Login type rule changes --- backend/package.json | 2 +- backend/src/routes/users.js | 24 +++++++-- build.sh | 2 +- frontend/package.json | 2 +- .../src/components/AddChildAliasModal.jsx | 52 +++++++++++++------ frontend/src/utils/api.js | 2 +- 6 files changed, 62 insertions(+), 22 deletions(-) diff --git a/backend/package.json b/backend/package.json index 49cf63c..4730176 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.52", + "version": "0.12.53", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 4efd468..d7ce2d1 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -192,6 +192,18 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => 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, id]); } + // Auto-unsuspend minor in players group if both guardian and DOB are now set + if (isMinor && guardianId && dob && target.status === 'suspended') { + const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'"); + const playersGroupId = parseInt(playersRow?.value); + if (playersGroupId) { + const inPlayers = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [id, playersGroupId]); + if (inPlayers) { + await exec(req.schema, "UPDATE users SET 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,must_change_password,last_online,created_at FROM users WHERE id=$1', [id] @@ -713,19 +725,25 @@ router.get('/minor-players', authMiddleware, async (req, res) => { }); // Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed) +// dateOfBirth is required to activate the minor — without it the guardian is saved but the account stays suspended. router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => { const minorId = parseInt(req.params.minorId); + const { dateOfBirth } = req.body; 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' }); + const dob = dateOfBirth || minor.date_of_birth || null; + const isMinor = dob ? isMinorFromDOB(dob) : minor.is_minor; + const shouldActivate = !!dob; + const newStatus = shouldActivate ? 'active' : 'suspended'; 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] + 'UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,date_of_birth=$2,is_minor=$3,status=$4,updated_at=NOW() WHERE id=$5', + [req.user.id, dob, isMinor, newStatus, minorId] ); - await addUserToPublicGroups(req.schema, minorId); + if (shouldActivate) 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] diff --git a/build.sh b/build.sh index bf94264..e1c2837 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.52}" +VERSION="${1:-0.12.53}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index e35d978..5244088 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.52", + "version": "0.12.53", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/AddChildAliasModal.jsx b/frontend/src/components/AddChildAliasModal.jsx index 2fe6007..9bb8c71 100644 --- a/frontend/src/components/AddChildAliasModal.jsx +++ b/frontend/src/components/AddChildAliasModal.jsx @@ -19,6 +19,7 @@ export default function AddChildAliasModal({ features = {}, onClose }) { // ── Mixed-age state (real minor users) ──────────────────────────────────── const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine const [selectedMinorId, setSelectedMinorId] = useState(''); + const [childDob, setChildDob] = useState(''); const [addingMinor, setAddingMinor] = useState(false); // ── Partner state (shared) ──────────────────────────────────────────────── @@ -49,6 +50,13 @@ export default function AddChildAliasModal({ features = {}, onClose }) { }).catch(() => {}); }, [isMixedAge]); + // Pre-populate DOB when a minor is selected from the dropdown + useEffect(() => { + if (!selectedMinorId) { setChildDob(''); return; } + const minor = availableMinors.find(u => u.id === parseInt(selectedMinorId)); + setChildDob(minor?.date_of_birth ? minor.date_of_birth.slice(0, 10) : ''); + }, [selectedMinorId]); // eslint-disable-line react-hooks/exhaustive-deps + // ── Helpers ─────────────────────────────────────────────────────────────── const set = k => e => setForm(p => ({ ...p, [k]: e.target.value })); @@ -164,12 +172,14 @@ export default function AddChildAliasModal({ features = {}, onClose }) { const handleAddMinor = async () => { if (!selectedMinorId) return; + if (!childDob.trim()) return toast('Date of Birth is required', 'error'); setAddingMinor(true); try { - await api.addGuardianChild(parseInt(selectedMinorId)); + await api.addGuardianChild(parseInt(selectedMinorId), childDob.trim()); const { users: fresh } = await api.getMinorPlayers(); setMinorPlayers(fresh || []); setSelectedMinorId(''); + setChildDob(''); toast('Child added and account activated', 'success'); } catch (e) { toast(e.message, 'error'); @@ -282,24 +292,36 @@ export default function AddChildAliasModal({ features = {}, onClose }) {
Add Child
-
- +
+ {lbl('Date of Birth', true)} + setSelectedMinorId(e.target.value)} - > - - {availableMinors.map(u => ( - - ))} - + type="text" + placeholder="YYYY-MM-DD" + value={childDob} + onChange={e => setChildDob(e.target.value)} + autoComplete="off" + style={childDob === '' && selectedMinorId ? { borderColor: 'var(--error)' } : {}} + /> +
+
- Pinch to zoom in the chat window also adjusts this setting. + Pinch to zoom adjusts font size for this session only.