v0.12.43 minor protection added

This commit is contained in:
2026-03-30 16:02:09 -04:00
parent e8e941c436
commit fe836ae69f
18 changed files with 1132 additions and 105 deletions

152
FEATURES.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.42",
"version": "0.12.43",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -21,6 +21,9 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
const [users, setUsers] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
// Mixed Age: guardian confirmation modal
const [guardianConfirm, setGuardianConfirm] = useState(null); // { group, guardianName }
const loginType = features.loginType || 'all_ages';
// True when exactly 1 user selected on private tab AND U2U messages are enabled
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
@@ -69,13 +72,18 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
};
}
const { group, duplicate } = await api.createGroup(payload);
const { group, duplicate, guardianAdded, guardianName } = await api.createGroup(payload);
if (duplicate) {
toast('A group with these members already exists — opening it now.', 'info');
onCreated(group);
} else {
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
if (guardianAdded && guardianName) {
setGuardianConfirm({ group, guardianName });
} else {
onCreated(group);
}
}
onCreated(group);
} catch (e) {
toast(e.message, 'error');
} finally {
@@ -172,6 +180,20 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
</button>
</div>
</div>
{guardianConfirm && (
<div className="modal-overlay">
<div className="modal" style={{ maxWidth: 360 }}>
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Added</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
<strong>{guardianConfirm.guardianName}</strong> has been added to this conversation as the guardian of this minor.
</p>
<div className="flex justify-end">
<button className="btn btn-primary" onClick={() => { setGuardianConfirm(null); onCreated(guardianConfirm.group); }}>OK</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -17,11 +17,13 @@ export default function ProfileModal({ onClose }) {
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
const [dob, setDob] = useState(user?.date_of_birth ? user.date_of_birth.slice(0, 10) : '');
const [phone, setPhone] = useState(user?.phone || '');
const [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' | 'add-child'
const [pushTesting, setPushTesting] = useState(false);
const [pushResult, setPushResult] = useState(null);
const [notifPermission, setNotifPermission] = useState(
@@ -32,6 +34,21 @@ export default function ProfileModal({ onClose }) {
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
// Minor age protection state
const [loginType, setLoginType] = useState('all_ages');
const [guardiansGroupId,setGuardiansGroupId] = useState(null);
const [showAddChild, setShowAddChild] = useState(false);
const [aliases, setAliases] = useState([]);
// Add Child form state
const [childList, setChildList] = useState([]); // pending aliases to add
const [childForm, setChildForm] = useState({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
const [childFormAvatar, setChildFormAvatar] = useState(null);
const [childSaving, setChildSaving] = useState(false);
// Mixed Age: minor user search
const [minorSearch, setMinorSearch] = useState('');
const [minorResults, setMinorResults] = useState([]);
const [selectedMinor, setSelectedMinor] = useState(null);
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
const [fontScale, setFontScale] = useState(
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
@@ -43,6 +60,29 @@ export default function ProfileModal({ onClose }) {
return () => window.removeEventListener('resize', onResize);
}, []);
// Load login type + check if user is in guardians group
useEffect(() => {
Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { groups }]) => {
const lt = s.feature_login_type || 'all_ages';
const gid = parseInt(s.feature_guardians_group_id);
setLoginType(lt);
setGuardiansGroupId(gid || null);
if (lt !== 'all_ages' && gid) {
const inGroup = (groups || []).some(g => g.id === gid);
setShowAddChild(inGroup);
}
}).catch(() => {});
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
}, []);
useEffect(() => {
if (loginType === 'mixed_age' && minorSearch.length >= 1) {
api.searchMinorUsers(minorSearch).then(({ users }) => setMinorResults(users || [])).catch(() => {});
} else {
setMinorResults([]);
}
}, [minorSearch, loginType]);
const applyFontScale = (val) => {
setFontScale(val);
document.documentElement.style.setProperty('--font-scale', val);
@@ -53,7 +93,7 @@ export default function ProfileModal({ onClose }) {
if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob || null, phone: phone || null });
updateUser(updated);
setSavedDisplayName(displayName);
toast('Profile updated', 'success');
@@ -64,6 +104,46 @@ export default function ProfileModal({ onClose }) {
}
};
const handleSaveChildren = async () => {
if (childList.length === 0) return;
setChildSaving(true);
try {
if (loginType === 'mixed_age') {
// Link each selected minor
for (const minor of childList) {
await api.linkMinor(minor.id);
}
toast('Guardian link request sent — awaiting manager approval', 'success');
} else {
// Create aliases
for (const child of childList) {
const { alias } = await api.createAlias({ firstName: child.firstName, lastName: child.lastName, email: child.email, dateOfBirth: child.dob, phone: child.phone });
if (child.avatarFile) {
await api.uploadAliasAvatar(alias.id, child.avatarFile);
}
}
toast('Children saved', 'success');
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
}
setChildList([]);
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
setSelectedMinor(null);
} catch (e) {
toast(e.message, 'error');
} finally {
setChildSaving(false);
}
};
const handleRemoveAlias = async (aliasId) => {
try {
await api.deleteAlias(aliasId);
setAliases(prev => prev.filter(a => a.id !== aliasId));
toast('Child removed', 'success');
} catch (e) { toast(e.message, 'error'); }
};
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -126,27 +206,17 @@ export default function ProfileModal({ onClose }) {
</div>
</div>
{/* Tabs — select on mobile, buttons on desktop */}
{isMobile ? (
<select
className="input"
value={tab}
onChange={e => { setTab(e.target.value); setPushResult(null); }}
style={{ marginBottom: 20 }}
>
{/* Tab navigation — unified select list on all screen sizes */}
<div style={{ marginBottom: 20 }}>
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<select className="input" value={tab} onChange={e => { setTab(e.target.value); setPushResult(null); }}>
<option value="profile">Profile</option>
<option value="password">Change Password</option>
<option value="notifications">Notifications</option>
<option value="appearance">Appearance</option>
{showAddChild && <option value="add-child">Add Child</option>}
</select>
) : (
<div className="flex gap-2" style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button>
<button className={`btn btn-sm ${tab === 'appearance' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('appearance')}>Appearance</button>
</div>
)}
</div>
{tab === 'profile' && (
<div className="flex-col gap-3">
@@ -206,6 +276,19 @@ export default function ProfileModal({ onClose }) {
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
Allow others to send me direct messages
</label>
{/* Date of Birth + Phone — visible in Guardian Only / Mixed Age modes */}
{loginType !== 'all_ages' && (
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
<input className="input" type="text" placeholder="YYYY-MM-DD" value={dob} onChange={e => setDob(e.target.value)} autoComplete="off" />
</div>
<div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Phone</label>
<input className="input" type="tel" placeholder="+1 555 000 0000" value={phone} onChange={e => setPhone(e.target.value)} autoComplete="tel" />
</div>
</div>
)}
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</button>
@@ -355,6 +438,122 @@ export default function ProfileModal({ onClose }) {
</div>
)}
{tab === 'add-child' && (
<div className="flex-col gap-3">
{/* Existing saved aliases */}
{aliases.length > 0 && (
<div>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>Saved Children</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 12 }}>
{aliases.map((a, i) => (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{a.first_name} {a.last_name}</span>
{a.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{a.date_of_birth.slice(0,10)}</span>}
<button onClick={() => handleRemoveAlias(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16, lineHeight: 1 }}>×</button>
</div>
))}
</div>
</div>
)}
{loginType === 'guardian_only' ? (
<>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add a Child</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10 }}>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>First Name *</label>
<input className="input" value={childForm.firstName} onChange={e => setChildForm(p=>({...p,firstName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Last Name *</label>
<input className="input" value={childForm.lastName} onChange={e => setChildForm(p=>({...p,lastName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
<input className="input" placeholder="YYYY-MM-DD" value={childForm.dob} onChange={e => setChildForm(p=>({...p,dob:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1">
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Phone</label>
<input className="input" type="tel" value={childForm.phone} onChange={e => setChildForm(p=>({...p,phone:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Email (optional)</label>
<input className="input" type="email" value={childForm.email} onChange={e => setChildForm(p=>({...p,email:e.target.value}))} autoComplete="off" />
</div>
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Avatar (optional)</label>
<input type="file" accept="image/*" onChange={e => setChildForm(p=>({...p,avatarFile:e.target.files?.[0]||null}))} />
</div>
</div>
<button className="btn btn-secondary btn-sm" style={{ alignSelf: 'flex-start' }}
onClick={() => {
if (!childForm.firstName.trim() || !childForm.lastName.trim()) return toast('First and last name required','error');
setChildList(prev => [...prev, { ...childForm }]);
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'', avatarFile:null });
}}>
+ Add
</button>
{childList.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{childList.map((c, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{c.firstName} {c.lastName}</span>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>Pending save</span>
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
</div>
))}
</div>
)}
</>
) : loginType === 'mixed_age' ? (
<>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Link a Child Account</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', margin: 0 }}>Search for a minor user account to link to your guardian profile. The link requires manager approval.</p>
<input className="input" placeholder="Search minor users..." value={minorSearch} onChange={e => setMinorSearch(e.target.value)} autoComplete="off" />
{minorResults.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 160, overflowY: 'auto' }}>
{minorResults.map(u => (
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}
onClick={() => { setSelectedMinor(u); setMinorSearch(''); setMinorResults([]); }}>
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
{u.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{u.date_of_birth.slice(0,10)}</span>}
</div>
))}
</div>
)}
{selectedMinor && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px' }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{selectedMinor.first_name} {selectedMinor.last_name}</div>
{selectedMinor.date_of_birth && <div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{selectedMinor.date_of_birth.slice(0,10)}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-secondary btn-sm"
onClick={() => { setChildList(prev => [...prev, selectedMinor]); setSelectedMinor(null); }}>
+ Add to list
</button>
<button className="btn btn-sm" style={{ background: 'none', border: '1px solid var(--border)' }} onClick={() => setSelectedMinor(null)}>Clear</button>
</div>
</div>
)}
{childList.length > 0 && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
{childList.map((c, i) => (
<div key={c.id || i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex: 1, fontSize: 14 }}>{c.first_name || c.firstName} {c.last_name || c.lastName}</span>
<span style={{ fontSize: 12, color: 'var(--warning)', fontStyle: 'italic' }}>Pending approval</span>
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
</div>
))}
</div>
)}
</>
) : null}
<button className="btn btn-primary" onClick={handleSaveChildren} disabled={childSaving || childList.length === 0}>
{childSaving ? 'Saving' : 'Save'}
</button>
</div>
)}
{tab === 'appearance' && (
<div className="flex-col gap-3">
<div className="flex-col gap-2">

View File

@@ -789,6 +789,11 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>')
const myAliases = event.my_aliases || [];
const showResponderSelect = !!(event.has_players_group && myAliases.length > 0);
const [responder, setResponder] = useState('all');
// Sync when parent reloads event after availability change
useEffect(()=>{
setMyResp(event.my_response);
@@ -802,6 +807,37 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const noteChanged = noteInput.trim() !== myNote.trim();
const handleResp=async resp=>{
// Guardian Only multi-responder logic
if (showResponderSelect) {
const note = noteInput.trim() || null;
// Build list of responders for this action
const targets = responder === 'all'
? [
...(event.in_guardians_group ? [{ type:'self' }] : []),
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
]
: responder === 'self'
? [{ type:'self' }]
: [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }];
try {
for (const t of targets) {
const prevResp = t.type === 'self'
? myResp
: (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null);
if (prevResp === resp) {
await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined);
} else {
await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined);
}
}
if (targets.some(t => t.type === 'self')) setMyResp(prev => prev === resp ? null : resp);
onAvailabilityChange?.(resp);
} catch(e) { toast(e.message,'error'); }
return;
}
// Normal (non-Guardian-Only) path
const prev=myResp;
const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update
@@ -826,6 +862,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const handleDownloadAvailability = () => {
// Format as "Lastname, Firstname" using first_name/last_name fields when available
const fmtName = u => {
// Alias entries have first_name/last_name directly
const last = (u.last_name || '').trim();
const first = (u.first_name || '').trim();
if (last && first) return `${last}, ${first}`;
@@ -937,6 +974,18 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</button>
))}
</div>
{/* Guardian Only: responder select — shown when event targets the players group and user has aliases */}
{showResponderSelect && (
<div style={{marginBottom:10}}>
<label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
<select value={responder} onChange={e=>setResponder(e.target.value)}
style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
<option value="all">All</option>
{event.in_guardians_group && <option value="self">{/* guardian's own name shown as self */}My own response</option>}
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
</select>
</div>
)}
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
<input
type="text"
@@ -965,16 +1014,21 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{avail.length>0&&(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
{avail.map(r=>{
const rowKey = r.is_alias ? `alias:${r.alias_id}` : `user:${r.user_id}`;
const displayName = r.is_alias
? `${r.first_name} ${r.last_name}`
: (r.display_name || r.name);
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(r.user_id);
const expanded=expandedNotes.has(rowKey);
return(
<div key={r.user_id} style={{borderBottom:'1px solid var(--border)'}}>
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(r.user_id):undefined}
onClick={hasNote?()=>toggleNote(rowKey):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{r.display_name||r.name}</span>
<span style={{flex:1}}>{displayName}</span>
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}

View File

@@ -158,6 +158,121 @@ function TeamManagementTab() {
);
}
// ── Login Type Tab ────────────────────────────────────────────────────────────
const LOGIN_TYPE_OPTIONS = [
{
id: 'all_ages',
label: 'All Ages',
desc: 'No age restrictions. All users interact normally. Default behaviour.',
},
{
id: 'guardian_only',
label: 'Guardian Only',
desc: "Parents are required to add their child's details in their profile. They respond on behalf of the child for events with availability tracking for the players group.",
},
{
id: 'mixed_age',
label: 'Mixed Age',
desc: "Parents, or user managers, add the minor's user account to their guardian profile. Minor aged users cannot login until a manager approves the guardian link.",
},
];
function LoginTypeTab() {
const toast = useToast();
const [loginType, setLoginType] = useState('all_ages');
const [playersGroupId, setPlayersGroupId] = useState('');
const [guardiansGroupId,setGuardiansGroupId] = useState('');
const [userGroups, setUserGroups] = useState([]);
const [canChange, setCanChange] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([api.getSettings(), api.getUserGroups()]).then(([{ settings: s }, { groups }]) => {
setLoginType(s.feature_login_type || 'all_ages');
setPlayersGroupId(s.feature_players_group_id || '');
setGuardiansGroupId(s.feature_guardians_group_id || '');
setUserGroups([...(groups || [])].sort((a, b) => a.name.localeCompare(b.name)));
}).catch(() => {});
// Determine if the user table is empty enough to allow changes
api.getUsers().then(({ users }) => {
const nonAdmins = (users || []).filter(u => u.role !== 'admin');
setCanChange(nonAdmins.length === 0);
}).catch(() => {});
}, []);
const handleSave = async () => {
setSaving(true);
try {
await api.updateLoginType({
loginType,
playersGroupId: playersGroupId ? parseInt(playersGroupId) : null,
guardiansGroupId: guardiansGroupId ? parseInt(guardiansGroupId) : null,
});
toast('Login Type settings saved', 'success');
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
const needsGroups = loginType !== 'all_ages';
return (
<div>
<div className="settings-section-label">Login Type</div>
{/* Warning */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px', marginBottom: 16 }}>
<span style={{ fontSize: 16, lineHeight: 1 }}></span>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0, lineHeight: 1.5 }}>
This setting can only be set or changed when the user table is empty (no non-admin users exist).
</p>
</div>
{/* Options */}
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
{LOGIN_TYPE_OPTIONS.map((opt, i) => (
<label key={opt.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px', borderBottom: i < LOGIN_TYPE_OPTIONS.length - 1 ? '1px solid var(--border)' : 'none', cursor: canChange ? 'pointer' : 'not-allowed', opacity: canChange ? 1 : 0.6 }}>
<input type="radio" name="loginType" value={opt.id} checked={loginType === opt.id} disabled={!canChange}
onChange={() => setLoginType(opt.id)} style={{ marginTop: 3, accentColor: 'var(--primary)' }} />
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{opt.label}</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>{opt.desc}</div>
</div>
</label>
))}
</div>
{/* Group selectors — only shown for Guardian Only / Mixed Age */}
{needsGroups && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Players Group</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>The user group that children / aliases are added to.</p>
<select className="input" value={playersGroupId} disabled={!canChange}
onChange={e => setPlayersGroupId(e.target.value)}>
<option value=""> Select group </option>
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Guardians Group</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Members of this group see the "Add Child" option in their profile.</p>
<select className="input" value={guardiansGroupId} disabled={!canChange}
onChange={e => setGuardiansGroupId(e.target.value)}>
<option value=""> Select group </option>
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
</div>
)}
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !canChange}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
);
}
// ── Registration Tab ──────────────────────────────────────────────────────────
function RegistrationTab({ onFeaturesChanged }) {
const toast = useToast();
@@ -295,12 +410,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
const isTeam = appType === 'RosterChirp-Team';
const tabs = [
{ id: 'messages', label: 'Messages' },
isTeam && { id: 'team', label: 'Tools' },
{ id: 'registration', label: 'Registration' },
].filter(Boolean);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 520 }}>
@@ -311,17 +420,20 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
</button>
</div>
{/* Tab buttons */}
<div className="flex gap-2" style={{ marginBottom: 24 }}>
{tabs.map(t => (
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
{/* Select navigation */}
<div style={{ marginBottom: 24 }}>
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<select className="input" value={tab} onChange={e => setTab(e.target.value)}>
<option value="messages">Messages</option>
{isTeam && <option value="team">Tools</option>}
<option value="login-type">Login Type</option>
<option value="registration">Registration</option>
</select>
</div>
{tab === 'messages' && <MessagesTab />}
{tab === 'team' && <TeamManagementTab />}
{tab === 'login-type' && <LoginTypeTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
</div>
</div>

View File

@@ -127,6 +127,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const msgPublic = features.msgPublic ?? true;
const msgU2U = features.msgU2U ?? true;
const msgPrivateGroup = features.msgPrivateGroup ?? true;
const loginType = features.loginType || 'all_ages';
const playersGroupId = features.playersGroupId ?? null;
const allGroups = [
...(groups.publicGroups || []),
@@ -143,6 +145,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
if (g.is_managed) return false;
if (g.is_direct && !msgU2U) return false;
if (!g.is_direct && !msgPrivateGroup) return false;
// Guardian Only: hide the managed DM channel for the designated players group
if (loginType === 'guardian_only' && g.is_managed && playersGroupId && g.source_user_group_id === playersGroupId) return false;
return true;
})].sort((a, b) => {
if (!a.last_message_at && !b.last_message_at) return 0;

View File

@@ -95,6 +95,8 @@ export default function Chat() {
msgGroup: settings.feature_msg_group !== 'false',
msgPrivateGroup: settings.feature_msg_private_group !== 'false',
msgU2U: settings.feature_msg_u2u !== 'false',
loginType: settings.feature_login_type || 'all_ages',
playersGroupId: settings.feature_players_group_id ? parseInt(settings.feature_players_group_id) : null,
}));
}).catch(() => {});
api.getMyUserGroups().then(({ userGroups }) => {

View File

@@ -89,10 +89,11 @@ function UserRow({ u, onUpdated, onEdit }) {
<Avatar user={u} size="sm" />
<div style={{ flex:1, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
<span style={{ fontWeight:600, fontSize:14 }}>{u.display_name || u.name}</span>
<span style={{ fontWeight:600, fontSize:14, color: u.guardian_approval_required ? 'var(--error)' : 'var(--text-primary)' }}>{u.display_name || u.name}</span>
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
<span className={`role-badge role-${u.role}`}>{u.role}</span>
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
{!!u.guardian_approval_required && <span className="role-badge" style={{ background:'var(--error)', color:'white' }}>Pending Guardian Approval</span>}
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
</div>
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
@@ -129,7 +130,7 @@ function UserRow({ u, onUpdated, onEdit }) {
}
// ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) {
function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast();
const isEdit = !!user;
@@ -164,6 +165,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
if (!lastName.trim()) return toast('Last name is required', 'error');
if (!isValidPhone(phone)) return toast('Invalid phone number', 'error');
if (!['member', 'admin', 'manager'].includes(role)) return toast('Role is required', 'error');
if (loginType === 'mixed_age' && !dob) return toast('Date of birth is required in Mixed Age mode', 'error');
if (isEdit && pwEnabled && (!password || password.length < 6))
return toast('New password must be at least 6 characters', 'error');
@@ -171,10 +173,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
try {
if (isEdit) {
await api.updateUser(user.id, {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
role,
dateOfBirth: dob || undefined,
guardianUserId: guardianId || undefined,
...(pwEnabled && password ? { password } : {}),
});
// Sync group memberships: add newly selected, remove deselected
@@ -187,11 +191,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
toast('User updated', 'success');
} else {
const { user: newUser } = await api.createUser({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
role,
dateOfBirth: dob || undefined,
...(password ? { password } : {}),
});
// Add to selected groups
@@ -278,24 +283,42 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
</div>
</div>
{/* Row 4: DOB + Guardian */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', false, '(optional)')}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }} />
{/* Row 4: DOB + Guardian — visible when loginType is not 'all_ages' */}
{loginType !== 'all_ages' && (
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', loginType === 'mixed_age', loginType === 'guardian_only' ? '(optional)' : undefined)}
<input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)}
autoComplete="off" onFocus={onIF} onBlur={onIB} />
</div>
{loginType === 'mixed_age' && isEdit && (
<div>
{lbl('Guardian', false, '(optional)')}
<div style={{ position:'relative' }}>
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
style={ user?.guardian_approval_required ? { borderColor:'var(--error)' } : {} }>
<option value=""> None </option>
{(nonMinorUsers || []).map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
{user?.guardian_approval_required && (
<div style={{ display:'flex', alignItems:'center', gap:8, marginTop:6 }}>
<span style={{ fontSize:12, color:'var(--error)', fontWeight:600 }}>Pending approval</span>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--success)', background:'none', border:'1px solid var(--success)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.approveGuardian(user.id); toast('Approved', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Approve
</button>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--error)', background:'none', border:'1px solid var(--error)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.denyGuardian(user.id); toast('Denied', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Deny
</button>
</div>
)}
</div>
)}
</div>
<div>
{lbl('Guardian', false, '(optional)')}
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }}>
<option value=""> Select guardian </option>
</select>
</div>
</div>
)}
{/* Row 4b: User Groups */}
{allUserGroups?.length > 0 && (
@@ -543,6 +566,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
const [editUser, setEditUser] = useState(null);
const [userPass, setUserPass] = useState('user@1234');
const [allUserGroups, setAllUserGroups] = useState([]);
const [loginType, setLoginType] = useState('all_ages');
const [inputFocused, setInputFocused] = useState(false);
const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false);
@@ -556,7 +580,10 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
useEffect(() => {
load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
api.getSettings().then(({ settings }) => {
if (settings.user_pass) setUserPass(settings.user_pass);
setLoginType(settings.feature_login_type || 'all_ages');
}).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {});
}, [load]);
@@ -664,6 +691,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
user={view === 'edit' ? editUser : null}
userPass={userPass}
allUserGroups={allUserGroups}
nonMinorUsers={users.filter(u => !u.is_minor && u.status === 'active')}
loginType={loginType}
onDone={() => { load(); goList(); }}
onCancel={goList}
isMobile={isMobile}

View File

@@ -69,6 +69,19 @@ export const api = {
const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form);
},
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
// Guardian aliases
getAliases: () => req('GET', '/users/me/aliases'),
createAlias: (body) => req('POST', '/users/me/aliases', body),
updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body),
deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),
uploadAliasAvatar: (aliasId, file) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', `/users/me/aliases/${aliasId}/avatar`, form);
},
// Groups
getGroups: () => req('GET', '/groups'),
@@ -105,6 +118,7 @@ export const api = {
registerCode: (code) => req('POST', '/settings/register', { code }),
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
updateMessageSettings: (body) => req('PATCH', '/settings/messages', body),
updateLoginType: (body) => req('PATCH', '/settings/login-type', body),
// Schedule Manager
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
@@ -120,9 +134,9 @@ export const api = {
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }),
setAvailability: (id, response, note) => req('PUT', `/schedule/${id}/availability`, { response, note }),
setAvailability: (id, response, note, aliasId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}) }),
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
deleteAvailability: (id, aliasId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : ''}`),
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => {