v0.12.46 host bug fixes and password reset feature,

This commit is contained in:
2026-03-31 12:21:59 -04:00
parent d0f10c4d7e
commit 350bb25ecd
9 changed files with 127 additions and 45 deletions

View File

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

View File

@@ -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 (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal">
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) {
<Field label="Display Name" value={form.name} onChange={set('name')} />
<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" />
<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 }}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>

View File

@@ -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);
}

View File

@@ -283,42 +283,41 @@ function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onD
</div>
</div>
{/* 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>
)}
{/* Row 4: DOB + Guardian */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Date of Birth', loginType === 'mixed_age', loginType !== 'mixed_age' ? '(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>
)}
{/* 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 */}
{allUserGroups?.length > 0 && (