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 && (