v0.12.46 host bug fixes and password reset feature,
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.45",
|
"version": "0.12.46",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -249,7 +249,21 @@ async function seedUserGroups(schema) {
|
|||||||
const existing = await queryOne(schema,
|
const existing = await queryOne(schema,
|
||||||
'SELECT id FROM user_groups WHERE name = $1', [name]
|
'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
|
// Create the managed DM chat group first
|
||||||
const gr = await queryResult(schema,
|
const gr = await queryResult(schema,
|
||||||
@@ -259,17 +273,31 @@ async function seedUserGroups(schema) {
|
|||||||
const dmGroupId = gr.rows[0].id;
|
const dmGroupId = gr.rows[0].id;
|
||||||
|
|
||||||
// Create the user group linked to the DM group
|
// Create the user group linked to the DM group
|
||||||
await exec(schema,
|
const ugr = await queryResult(schema,
|
||||||
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING',
|
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||||
[name, dmGroupId]
|
[name, dmGroupId]
|
||||||
);
|
);
|
||||||
|
const ugId = ugr.rows[0]?.id;
|
||||||
console.log(`[DB:${schema}] Default user group created: ${name}`);
|
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) {
|
async function seedAdmin(schema) {
|
||||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
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 adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module.exports = function(io) {
|
|||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { email, password, rememberMe } = req.body;
|
const { email, password, rememberMe } = req.body;
|
||||||
try {
|
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) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
if (user.status === 'suspended') {
|
if (user.status === 'suspended') {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const {
|
const {
|
||||||
query, queryOne, queryResult, exec,
|
query, queryOne, queryResult, exec,
|
||||||
@@ -186,7 +187,7 @@ router.post('/tenants', async (req, res) => {
|
|||||||
// Supports updating: name, plan, customDomain, status
|
// Supports updating: name, plan, customDomain, status
|
||||||
|
|
||||||
router.patch('/tenants/:slug', async (req, res) => {
|
router.patch('/tenants/:slug', async (req, res) => {
|
||||||
const { name, plan, customDomain, status } = req.body;
|
const { name, plan, customDomain, status, adminPassword } = req.body;
|
||||||
try {
|
try {
|
||||||
const tenant = await queryOne('public',
|
const tenant = await queryOne('public',
|
||||||
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
'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]);
|
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();
|
await reloadTenantCache();
|
||||||
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||||
res.json({ tenant: updated });
|
res.json({ tenant: updated });
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.45}"
|
VERSION="${1:-0.12.46}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.45",
|
"version": "0.12.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -164,21 +164,28 @@ function ProvisionModal({ api, baseDomain, onClose, onDone, toast }) {
|
|||||||
|
|
||||||
function EditModal({ api, tenant, onClose, onDone }) {
|
function EditModal({ api, tenant, onClose, onDone }) {
|
||||||
const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' });
|
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 [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
const handle = async () => {
|
const handle = async () => {
|
||||||
|
if (adminPassword && adminPassword.length < 6)
|
||||||
|
return setError('Admin password must be at least 6 characters');
|
||||||
setSaving(true); setError('');
|
setSaving(true); setError('');
|
||||||
try {
|
try {
|
||||||
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
||||||
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
|
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
|
||||||
|
...(adminPassword ? { adminPassword } : {}),
|
||||||
});
|
});
|
||||||
onDone(updated);
|
onDone(updated);
|
||||||
} catch (e) { setError(e.message); }
|
} catch (e) { setError(e.message); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adminEmail = tenant.admin_email || '(uses system default from .env)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) {
|
|||||||
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
||||||
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||||
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
||||||
|
<div style={{ borderTop:'1px solid var(--border)', paddingTop:12 }}>
|
||||||
|
<div style={{ fontSize:11, fontWeight:700, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:10 }}>Admin Account</div>
|
||||||
|
<FieldGroup label="Login Email (read-only)">
|
||||||
|
<input type="text" value={adminEmail} readOnly
|
||||||
|
className="input" style={{ fontSize:13, opacity:0.7, cursor:'default' }} />
|
||||||
|
</FieldGroup>
|
||||||
|
<div style={{ marginTop:10 }}>
|
||||||
|
<FieldGroup label="Reset Admin Password" >
|
||||||
|
<div style={{ position:'relative' }}>
|
||||||
|
<input
|
||||||
|
type={showAdminPass ? 'text' : 'password'}
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={e => setAdminPassword(e.target.value)}
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input"
|
||||||
|
style={{ fontSize:13, paddingRight:40 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdminPass(v => !v)}
|
||||||
|
style={{ position:'absolute', right:10, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', color:'var(--text-tertiary)', padding:0, display:'flex', alignItems:'center' }}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showAdminPass ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize:11, color:'var(--text-tertiary)' }}>Admin will be required to change password on next login</span>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
||||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ export default function ProfileModal({ onClose }) {
|
|||||||
const gid = parseInt(s.feature_guardians_group_id);
|
const gid = parseInt(s.feature_guardians_group_id);
|
||||||
setLoginType(lt);
|
setLoginType(lt);
|
||||||
setGuardiansGroupId(gid || null);
|
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);
|
const inGroup = (userGroups || []).some(g => g.id === gid);
|
||||||
setShowAddChild(inGroup);
|
setShowAddChild(inGroup);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,42 +283,41 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: DOB + Guardian — visible when loginType is not 'all_ages' */}
|
{/* Row 4: DOB + Guardian */}
|
||||||
{loginType !== 'all_ages' && (
|
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
|
||||||
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
|
<div>
|
||||||
<div>
|
{lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(optional)' : undefined)}
|
||||||
{lbl('Date of Birth', loginType === 'mixed_age', loginType === 'guardian_only' ? '(optional)' : undefined)}
|
<input className="input" type="text" placeholder="YYYY-MM-DD"
|
||||||
<input className="input" type="text" placeholder="YYYY-MM-DD"
|
value={dob} onChange={e => setDob(e.target.value)}
|
||||||
value={dob} onChange={e => setDob(e.target.value)}
|
autoComplete="off" onFocus={onIF} onBlur={onIB} />
|
||||||
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>
|
||||||
)}
|
{/* Guardian field — shown for all login types except guardian_only (children are aliases there, not users) */}
|
||||||
|
{loginType !== 'guardian_only' && (
|
||||||
|
<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>
|
||||||
|
{isEdit && 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>
|
||||||
|
|
||||||
{/* Row 4b: User Groups */}
|
{/* Row 4b: User Groups */}
|
||||||
{allUserGroups?.length > 0 && (
|
{allUserGroups?.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user