v0.12.12 user manager update

This commit is contained in:
2026-03-24 07:34:03 -04:00
parent dec24eb842
commit 2e3e4100f5
8 changed files with 327 additions and 161 deletions

View File

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

View File

@@ -0,0 +1,17 @@
-- Migration 009: Extended user profile fields
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
-- Back-fill first_name / last_name from existing combined name for non-deleted users
UPDATE users
SET
first_name = SPLIT_PART(TRIM(name), ' ', 1),
last_name = CASE
WHEN POSITION(' ' IN TRIM(name)) > 0
THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
ELSE NULL
END
WHERE first_name IS NULL
AND TRIM(name) NOT IN ('Deleted User', '');

View File

@@ -33,7 +33,7 @@ function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try { try {
const users = await query(req.schema, const users = await query(req.schema,
"SELECT id,name,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 created_at ASC" "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"
); );
res.json({ users }); res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
@@ -82,26 +82,66 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user // Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, email, password, role } = req.body; const { firstName, lastName, email, password, role, phone, isMinor } = req.body;
if (!name || !email) return res.status(400).json({ error: 'Name and email required' }); if (!firstName?.trim() || !lastName?.trim() || !email)
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' }); 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' });
const validRoles = ['member', 'admin', 'manager'];
const assignedRole = validRoles.includes(role) ? role : 'member';
const name = `${firstName.trim()} ${lastName.trim()}`;
try { try {
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email = $1 AND status != 'deleted'", [email]); 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' }); if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name.trim()); const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234'; const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10); const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema, const r = await queryResult(req.schema,
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id", "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, email, hash, role === 'admin' ? 'admin' : 'member'] [resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, !!isMinor]
); );
const userId = r.rows[0].id; const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId); await addUserToPublicGroups(req.schema, userId);
if (role === 'admin') { if (assignedRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema); 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]); 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,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [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 });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Update user (general — name components, phone, 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;
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 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 resolvedName = await resolveUniqueName(req.schema, name, 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]
);
if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10);
await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]);
}
if (role === 'admin' && target.role !== '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, 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',
[id]
);
res.json({ user }); res.json({ user });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -159,7 +199,7 @@ router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res
// Patch role // Patch role
router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => { router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { role } = req.body; const { role } = req.body;
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
try { try {
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]); const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
if (!target) return res.status(404).json({ error: 'User not found' }); if (!target) return res.status(404).json({ error: 'User not found' });
@@ -216,6 +256,10 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
status = 'deleted', status = 'deleted',
email = $1, email = $1,
name = 'Deleted User', name = 'Deleted User',
first_name = NULL,
last_name = NULL,
phone = NULL,
is_minor = FALSE,
display_name = NULL, display_name = NULL,
avatar = NULL, avatar = NULL,
about_me = NULL, about_me = NULL,

View File

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

View File

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

View File

@@ -194,6 +194,7 @@ a { color: inherit; text-decoration: none; }
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase;
} }
.role-admin { background: #fce8e6; color: #c5221f; } .role-admin { background: #fce8e6; color: #c5221f; }
.role-manager { background: #e6f4ea; color: #1e7e34; }
.role-member { background: var(--primary-light); color: var(--primary); } .role-member { background: var(--primary-light); color: var(--primary); }
.status-suspended { background: #fff3e0; color: #e65100; } .status-suspended { background: #fff3e0; color: #e65100; }

View File

@@ -3,10 +3,16 @@ import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import Avatar from '../components/Avatar.jsx'; import Avatar from '../components/Avatar.jsx';
import UserFooter from '../components/UserFooter.jsx'; import UserFooter from '../components/UserFooter.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
const SIDEBAR_W = 320; const SIDEBAR_W = 320;
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); } function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
function isValidPhone(p) {
if (!p || !p.trim()) return true;
const digits = p.replace(/[\s\-\(\)\+\.x#]/g, '');
return /^\d{7,15}$/.test(digits);
}
function parseCSV(text) { function parseCSV(text) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
@@ -24,38 +30,11 @@ function parseCSV(text) {
return { rows, invalid }; return { rows, invalid };
} }
function UserRow({ u, onUpdated }) { // ── User Row (accordion list item) ───────────────────────────────────────────
function UserRow({ u, onUpdated, onEdit }) {
const toast = useToast(); const toast = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [resetPw, setResetPw] = useState('');
const [showReset, setShowReset] = useState(false);
const [editName, setEditName] = useState(false);
const [nameVal, setNameVal] = useState(u.name);
const [roleWarning, setRoleWarning] = useState(false);
// onIF/onIB are no-ops here — UserRow doesn't have access to the page-level
// inputFocused state. The mobile footer is controlled by the parent page only.
const onIF = () => {};
const onIB = () => {};
const handleRole = async (role) => {
if (!role) { setRoleWarning(true); return; }
setRoleWarning(false);
try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleResetPw = async () => {
if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error');
try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleSaveName = async () => {
if (!nameVal.trim()) return toast('Name cannot be empty', 'error');
try {
const { name } = await api.updateName(u.id, nameVal.trim());
toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success');
setEditName(false); onUpdated();
} catch (e) { toast(e.message, 'error'); }
};
const handleSuspend = async () => { const handleSuspend = async () => {
if (!confirm(`Suspend ${u.name}?`)) return; if (!confirm(`Suspend ${u.name}?`)) return;
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); } try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
@@ -74,7 +53,7 @@ function UserRow({ u, onUpdated }) {
return ( return (
<div style={{ borderBottom: '1px solid var(--border)' }}> <div style={{ borderBottom: '1px solid var(--border)' }}>
<button onClick={() => { setOpen(o => !o); setShowReset(false); setEditName(false); }} <button onClick={() => setOpen(o => !o)}
style={{ width:'100%', display:'flex', alignItems:'center', gap:10, padding:'10px 12px', style={{ width:'100%', display:'flex', alignItems:'center', gap:10, padding:'10px 12px',
background:'none', border:'none', cursor:'pointer', textAlign:'left', color:'var(--text-primary)' }}> background:'none', border:'none', cursor:'pointer', textAlign:'left', color:'var(--text-primary)' }}>
<Avatar user={u} size="sm" /> <Avatar user={u} size="sm" />
@@ -87,17 +66,6 @@ function UserRow({ u, onUpdated }) {
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>} {!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
</div> </div>
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div> <div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
<div style={{ fontSize:11, color:'var(--text-tertiary)', marginTop:1 }}>
Last online: {(() => {
if (!u.last_online) return 'Never';
const d = new Date(u.last_online); const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate()-1);
const dd = new Date(d); dd.setHours(0,0,0,0);
if (dd >= today) return 'Today'; if (dd >= yesterday) return 'Yesterday';
return dd.toISOString().slice(0,10);
})()}
</div>
{!!u.must_change_password && <div className="text-xs" style={{ color:'var(--warning)' }}> Must change password</div>}
</div> </div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ flexShrink:0, transition:'transform 0.2s', transform:open?'rotate(180deg)':'none', color:'var(--text-tertiary)' }}> style={{ flexShrink:0, transition:'transform 0.2s', transform:open?'rotate(180deg)':'none', color:'var(--text-tertiary)' }}>
@@ -106,56 +74,15 @@ function UserRow({ u, onUpdated }) {
</button> </button>
{open && !u.is_default_admin && ( {open && !u.is_default_admin && (
<div style={{ padding:'4px 12px 14px 52px', display:'flex', flexDirection:'column', gap:10 }}> <div style={{ padding:'6px 12px 12px', display:'flex', alignItems:'center', gap:8 }}>
{editName ? ( <button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button>
<div style={{ display:'flex', gap:6, alignItems:'center' }}> <div style={{ marginLeft:'auto', display:'flex', gap:8 }}>
<input className="input" style={{ flex:1, fontSize:13, padding:'5px 8px' }} {u.status === 'active' ? (
value={nameVal} onChange={e => setNameVal(e.target.value)} <button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button>
onKeyDown={e => { if(e.key==='Enter') handleSaveName(); if(e.key==='Escape'){setEditName(false);setNameVal(u.name);} }} ) : u.status === 'suspended' ? (
autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> <button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button>
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}></button>
</div>
) : (
<button className="btn btn-secondary btn-sm" style={{ display:'flex', alignItems:'center', gap:5, alignSelf:'flex-start' }}
onClick={() => { setEditName(true); setShowReset(false); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit Name
</button>
)}
<div style={{ display:'flex', flexDirection:'column', alignItems:'flex-start', gap:4 }}>
<select value={roleWarning ? '' : u.role} onChange={e => handleRole(e.target.value)}
className="input" style={{ width:140, padding:'5px 8px', fontSize:13, borderColor:roleWarning?'#e53935':undefined }}>
<option value="" disabled>User Role</option>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
{roleWarning && <span style={{ fontSize:12, color:'#e53935' }}>Role Required</span>}
</div>
{showReset ? (
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
<input className="input" style={{ flex:1, fontSize:13, padding:'5px 8px' }}
type="text" placeholder="New password (min 6)" value={resetPw}
onChange={e => setResetPw(e.target.value)}
onKeyDown={e => { if(e.key==='Enter') handleResetPw(); if(e.key==='Escape'){setShowReset(false);setResetPw('');} }}
autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}></button>
</div>
) : (
<button className="btn btn-secondary btn-sm" style={{ display:'flex', alignItems:'center', gap:5, alignSelf:'flex-start' }}
onClick={() => { setShowReset(true); setEditName(false); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Reset Password
</button>
)}
<div style={{ display:'flex', gap:6, flexWrap:'wrap' }}>
{u.status==='active' ? (
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
) : u.status==='suspended' ? (
<button className="btn btn-secondary btn-sm" style={{ color:'var(--success)' }} onClick={handleActivate}>Activate</button>
) : null} ) : null}
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete User</button> <button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete</button>
</div> </div>
</div> </div>
)} )}
@@ -163,49 +90,208 @@ function UserRow({ u, onUpdated }) {
); );
} }
function CreateUserForm({ userPass, onCreated, isMobile, onIF, onIB }) { // ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast(); const toast = useToast();
const [form, setForm] = useState({ name:'', email:'', password:'', role:'member' }); const isEdit = !!user;
const [saving, setSaving] = useState(false);
const set = k => v => setForm(f => ({ ...f, [k]: v })); const [firstName, setFirstName] = useState(user?.first_name || '');
const handle = async () => { const [lastName, setLastName] = useState(user?.last_name || '');
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error'); const [email, setEmail] = useState(user?.email || '');
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error'); const [phone, setPhone] = useState(user?.phone || '');
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error'); const [role, setRole] = useState(user?.role || 'member');
setSaving(true); const [isMinor, setIsMinor] = useState(!!user?.is_minor);
try { await api.createUser(form); toast('User created', 'success'); setForm({ name:'', email:'', password:'', role:'member' }); onCreated(); } const [password, setPassword] = useState('');
catch(e) { toast(e.message, 'error'); } const [pwEnabled, setPwEnabled] = useState(!isEdit);
finally { setSaving(false); } const [saving, setSaving] = useState(false);
const fmtLastLogin = (ts) => {
if (!ts) return 'Never';
const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
const dd = new Date(d); dd.setHours(0,0,0,0);
if (dd >= today) return 'Today';
if (dd >= yesterday) return 'Yesterday';
return dd.toISOString().slice(0, 10);
}; };
const handleSubmit = async () => {
if (!isEdit && (!email.trim() || !isValidEmail(email.trim())))
return toast('Valid email address required', 'error');
if (!firstName.trim()) return toast('First name is required', 'error');
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 (!isEdit && (!password || password.length < 6))
return toast('Password must be at least 6 characters', 'error');
if (isEdit && pwEnabled && (!password || password.length < 6))
return toast('New password must be at least 6 characters', 'error');
setSaving(true);
try {
if (isEdit) {
await api.updateUser(user.id, {
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
isMinor,
role,
...(pwEnabled && password ? { password } : {}),
});
toast('User updated', 'success');
} else {
await api.createUser({
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
isMinor,
role,
password,
});
toast('User created', 'success');
}
onDone();
} catch (e) {
toast(e.message, 'error');
} finally {
setSaving(false);
}
};
const colGrid = isMobile ? '1fr' : '1fr 1fr';
const lbl = (text, required, note) => (
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)', display:'block', marginBottom:4 }}>
{text}
{required && <span style={{ color:'var(--error)', marginLeft:2 }}>*</span>}
{note && <span style={{ fontSize:11, color:'var(--text-tertiary)', fontWeight:400, marginLeft:6 }}>{note}</span>}
</label>
);
return ( return (
<div style={{ maxWidth:560 }}> <div style={{ maxWidth: isMobile ? '100%' : 580 }}>
<div style={{ display:'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap:12, marginBottom:12 }}>
<div className="flex-col gap-1"> {/* Back + title */}
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Full Name <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>(First Last)</span></label> <div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20 }}>
<input className="input" placeholder="Jane Smith" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" value={form.name} onChange={e => set('name')(e.target.value)} onFocus={onIF} onBlur={onIB} /> <button onClick={onCancel} className="btn btn-secondary btn-sm"
style={{ display:'flex', alignItems:'center', gap:4, flexShrink:0 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6"/>
</svg>
Back
</button>
<span style={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>
{isEdit ? 'Edit User' : 'Create User'}
</span>
</div>
{/* Row 1: Login (email) — full width */}
<div style={{ marginBottom:12 }}>
{lbl('Login (email)', !isEdit)}
<input className="input" type="email" placeholder="user@example.com"
value={email} onChange={e => setEmail(e.target.value)}
disabled={isEdit}
style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }}
autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
</div>
{/* Row 2: First Name + Last Name */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('First Name', true)}
<input className="input" placeholder="Jane"
value={firstName} onChange={e => setFirstName(e.target.value)}
autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
</div> </div>
<div className="flex-col gap-1"> <div>
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Email</label> {lbl('Last Name', true)}
<input className="input" type="email" placeholder="jane@example.com" autoComplete="new-password" value={form.email} onChange={e => set('email')(e.target.value)} onFocus={onIF} onBlur={onIB} /> <input className="input" placeholder="Smith"
value={lastName} onChange={e => setLastName(e.target.value)}
autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
</div> </div>
<div className="flex-col gap-1"> </div>
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>(blank = {userPass})</span></label>
<input className="input" type="text" autoComplete="new-password" value={form.password} onChange={e => set('password')(e.target.value)} onFocus={onIF} onBlur={onIB} /> {/* Row 3: Phone + Role */}
<div style={{ display:'grid', gridTemplateColumns:colGrid, gap:12, marginBottom:12 }}>
<div>
{lbl('Phone', false, '(optional)')}
<input className="input" type="tel" placeholder="+1 555 000 0000"
value={phone} onChange={e => setPhone(e.target.value)}
autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
</div> </div>
<div className="flex-col gap-1"> <div>
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Role</label> {lbl('App Role', true)}
<select className="input" value={form.role} onChange={e => set('role')(e.target.value)}> <select className="input" value={role} onChange={e => setRole(e.target.value)}>
<option value="member">Member</option> <option value="member">Member</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
</div> </div>
</div> </div>
<p className="text-xs" style={{ color:'var(--text-secondary)', marginBottom:12 }}>User must change password on first login. Duplicate names get a number suffix.</p>
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Creating…' : 'Create User'}</button> {/* Row 4: Is minor */}
<div style={{ marginBottom:12 }}>
<label style={{ display:'flex', alignItems:'center', gap:8, cursor:'pointer', fontSize:14, color:'var(--text-primary)', userSelect:'none' }}>
<input type="checkbox" checked={isMinor} onChange={e => setIsMinor(e.target.checked)}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
User is a minor
</label>
</div>
{/* Row 5: Password */}
<div style={{ marginBottom:16 }}>
{lbl('Password',
(!isEdit) || (isEdit && pwEnabled),
isEdit && !pwEnabled ? '(not changing — click Reset Password to set a new one)' :
!isEdit ? `(blank = ${userPass})` : null
)}
<div style={{ opacity: pwEnabled ? 1 : 0.55 }}>
<PasswordInput
value={password} onChange={e => setPassword(e.target.value)}
placeholder={isEdit && !pwEnabled ? '••••••••' : 'Min 6 characters'}
disabled={!pwEnabled}
autoComplete="new-password"
onFocus={onIF} onBlur={onIB}
/>
</div>
</div>
{/* Row 6: Buttons */}
<div style={{ display:'flex', alignItems:'center', gap:8, flexWrap:'wrap', marginBottom:10 }}>
<button className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
{saving ? 'Saving…' : isEdit ? 'Save Changes' : 'Create User'}
</button>
{isEdit && !pwEnabled && (
<button className="btn btn-sm" style={{ background:'var(--error)', color:'white' }}
onClick={() => setPwEnabled(true)}>
Reset Password
</button>
)}
{isEdit && pwEnabled && (
<button className="btn btn-secondary btn-sm"
onClick={() => { setPwEnabled(false); setPassword(''); }}>
Cancel Reset
</button>
)}
<button className="btn btn-secondary" onClick={onCancel} style={{ marginLeft:'auto' }}>
Cancel
</button>
</div>
{/* Row 7 (edit only): Last login + must change password */}
{isEdit && (
<div style={{ display:'flex', alignItems:'center', gap:14, flexWrap:'wrap', fontSize:12, color:'var(--text-tertiary)', paddingTop:4, borderTop:'1px solid var(--border)' }}>
<span>Last Login: <strong style={{ color:'var(--text-secondary)' }}>{fmtLastLogin(user.last_online)}</strong></span>
{!!user.must_change_password && (
<span style={{ color:'var(--warning)', fontWeight:600 }}> Must change password</span>
)}
</div>
)}
</div> </div>
); );
} }
// ── Bulk Import Form ──────────────────────────────────────────────────────────
function BulkImportForm({ userPass, onCreated }) { function BulkImportForm({ userPass, onCreated }) {
const toast = useToast(); const toast = useToast();
const fileRef = useRef(null); const fileRef = useRef(null);
@@ -278,12 +364,13 @@ function BulkImportForm({ userPass, onCreated }) {
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) { export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [tab, setTab] = useState('users'); const [view, setView] = useState('list'); // 'list' | 'create' | 'edit' | 'bulk'
const [userPass, setUserPass] = useState('user@1234'); const [editUser, setEditUser] = useState(null);
const [userPass, setUserPass] = useState('user@1234');
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const onIF = () => setInputFocused(true); const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false); const onIB = () => setInputFocused(false);
@@ -308,35 +395,39 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
) )
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
// ── Nav item helper (matches Schedule page style) ───────────────────────── const goList = () => { setView('list'); setEditUser(null); };
const navItem = (label, key) => ( const goCreate = () => { setView('create'); setEditUser(null); };
<button key={key} onClick={() => setTab(key)} const goEdit = (u) => { setView('edit'); setEditUser(u); };
const goBulk = () => { setView('bulk'); setEditUser(null); };
const navItem = (label, active, onClick) => (
<button onClick={onClick}
style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px', style={{ display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
borderRadius:'var(--radius)', border:'none', borderRadius:'var(--radius)', border:'none',
background: tab===key ? 'var(--primary-light)' : 'transparent', background: active ? 'var(--primary-light)' : 'transparent',
color: tab===key ? 'var(--primary)' : 'var(--text-primary)', color: active ? 'var(--primary)' : 'var(--text-primary)',
cursor:'pointer', fontWeight: tab===key ? 600 : 400, fontSize:14, marginBottom:2 }}> cursor:'pointer', fontWeight: active ? 600 : 400, fontSize:14, marginBottom:2 }}>
{label} {label}
</button> </button>
); );
const isFormView = view === 'create' || view === 'edit';
return ( return (
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}> <div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
{/* ── Left panel ── */} {/* ── Desktop sidebar ── */}
{!isMobile && ( {!isMobile && (
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}> <div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
<div style={{ padding:'16px 16px 0' }}> <div style={{ padding:'16px 16px 0' }}>
{/* Title — matches Schedule page */}
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}> <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span style={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>User Manager</span> <span style={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>User Manager</span>
</div> </div>
{/* Tab navigation */}
<div className="section-label" style={{ marginBottom:6 }}>View</div> <div className="section-label" style={{ marginBottom:6 }}>View</div>
{navItem(`All Users${!loading ? ` (${users.length})` : ''}`, 'users')} {navItem(`All Users${!loading ? ` (${users.length})` : ''}`, view === 'list' || view === 'edit', goList)}
{navItem('+ Create User', 'create')} {navItem('+ Create User', view === 'create', goCreate)}
{navItem('Bulk Import CSV', 'bulk')} {navItem('Bulk Import CSV', view === 'bulk', goBulk)}
</div> </div>
<div style={{ flex:1 }} /> <div style={{ flex:1 }} />
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} /> <UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
@@ -346,27 +437,27 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{/* ── Right panel ── */} {/* ── Right panel ── */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}> <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
{/* Mobile tab bar — only on mobile since desktop uses left panel */} {/* Mobile tab bar */}
{isMobile && ( {isMobile && (
<div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 12px', display:'flex', gap:6, height:48, alignItems:'center', flexShrink:0 }}> <div style={{ background:'var(--surface)', borderBottom:'1px solid var(--border)', padding:'0 12px', display:'flex', gap:6, height:48, alignItems:'center', flexShrink:0 }}>
<span style={{ fontWeight:700, fontSize:14, marginRight:4, color:'var(--text-primary)' }}>Users</span> <span style={{ fontWeight:700, fontSize:14, marginRight:4, color:'var(--text-primary)' }}>Users</span>
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>All</button> <button className={`btn btn-sm ${!isFormView && view !== 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={goList}>All</button>
<button className={`btn btn-sm ${tab==='create'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('create')}>+ Create</button> <button className={`btn btn-sm ${isFormView ? 'btn-primary' : 'btn-secondary'}`} onClick={goCreate}>+ Create</button>
</div> </div>
)} )}
{/* Content */} {/* Content */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}> <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}>
{tab === 'users' && (
{/* LIST VIEW */}
{view === 'list' && (
<> <>
{/* Search — always visible, outside scroll area */}
<div style={{ padding:'16px 16px 8px', flexShrink:0 }}> <div style={{ padding:'16px 16px 8px', flexShrink:0 }}>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} <input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
onFocus={onIF} onBlur={onIB} onFocus={onIF} onBlur={onIB}
autoComplete="new-password" autoCorrect="off" spellCheck={false} autoComplete="new-password" autoCorrect="off" spellCheck={false}
style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} /> style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
</div> </div>
{/* User list — bounded scroll */}
<div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}>
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}> <div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
{loading ? ( {loading ? (
@@ -381,25 +472,37 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{search ? 'No users match your search.' : 'No users yet.'} {search ? 'No users match your search.' : 'No users yet.'}
</div> </div>
) : ( ) : (
filtered.map(u => <UserRow key={u.id} u={u} onUpdated={load} />) filtered.map(u => <UserRow key={u.id} u={u} onUpdated={load} onEdit={goEdit} />)
)} )}
</div> </div>
</div> </div>
</> </>
)} )}
{tab === 'create' && (
{/* CREATE / EDIT FORM */}
{isFormView && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}>
<CreateUserForm userPass={userPass} onCreated={() => { load(); setTab('users'); }} isMobile={isMobile} onIF={onIF} onIB={onIB} /> <UserForm
user={view === 'edit' ? editUser : null}
userPass={userPass}
onDone={() => { load(); goList(); }}
onCancel={goList}
isMobile={isMobile}
onIF={onIF}
onIB={onIB}
/>
</div> </div>
)} )}
{tab === 'bulk' && (
{/* BULK IMPORT */}
{view === 'bulk' && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}>
<BulkImportForm userPass={userPass} onCreated={load} /> <BulkImportForm userPass={userPass} onCreated={load} />
</div> </div>
)} )}
</div> </div>
{/* Mobile footer — fixed, hidden when any input is focused (keyboard open) */} {/* Mobile footer — fixed, hidden when keyboard is up */}
{isMobile && !inputFocused && ( {isMobile && !inputFocused && (
<div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}> <div style={{ position:'fixed', bottom:0, left:0, right:0, zIndex:20, background:'var(--surface)', borderTop:'1px solid var(--border)' }}>
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} /> <UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />

View File

@@ -55,6 +55,7 @@ export const api = {
getUsers: () => req('GET', '/users'), getUsers: () => req('GET', '/users'),
searchUsers: (q, groupId) => req('GET', `/users/search?q=${encodeURIComponent(q)}${groupId ? `&groupId=${groupId}` : ''}`), searchUsers: (q, groupId) => req('GET', `/users/search?q=${encodeURIComponent(q)}${groupId ? `&groupId=${groupId}` : ''}`),
createUser: (body) => req('POST', '/users', body), createUser: (body) => req('POST', '/users', body),
updateUser: (id, body) => req('PATCH', `/users/${id}`, body),
bulkUsers: (users) => req('POST', '/users/bulk', { users }), bulkUsers: (users) => req('POST', '/users/bulk', { users }),
updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }), updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }),
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }), updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),