|
|
|
|
@@ -3,10 +3,16 @@ import { useToast } from '../contexts/ToastContext.jsx';
|
|
|
|
|
import { api } from '../utils/api.js';
|
|
|
|
|
import Avatar from '../components/Avatar.jsx';
|
|
|
|
|
import UserFooter from '../components/UserFooter.jsx';
|
|
|
|
|
import PasswordInput from '../components/PasswordInput.jsx';
|
|
|
|
|
|
|
|
|
|
const SIDEBAR_W = 320;
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
|
|
|
@@ -24,38 +30,11 @@ function parseCSV(text) {
|
|
|
|
|
return { rows, invalid };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function UserRow({ u, onUpdated }) {
|
|
|
|
|
// ── User Row (accordion list item) ───────────────────────────────────────────
|
|
|
|
|
function UserRow({ u, onUpdated, onEdit }) {
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
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 () => {
|
|
|
|
|
if (!confirm(`Suspend ${u.name}?`)) return;
|
|
|
|
|
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
|
|
|
|
|
@@ -74,7 +53,7 @@ function UserRow({ u, onUpdated }) {
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<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',
|
|
|
|
|
background:'none', border:'none', cursor:'pointer', textAlign:'left', color:'var(--text-primary)' }}>
|
|
|
|
|
<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>}
|
|
|
|
|
</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>
|
|
|
|
|
<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)' }}>
|
|
|
|
|
@@ -106,56 +74,15 @@ function UserRow({ u, onUpdated }) {
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && !u.is_default_admin && (
|
|
|
|
|
<div style={{ padding:'4px 12px 14px 52px', display:'flex', flexDirection:'column', gap:10 }}>
|
|
|
|
|
{editName ? (
|
|
|
|
|
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
|
|
|
|
<input className="input" style={{ flex:1, fontSize:13, padding:'5px 8px' }}
|
|
|
|
|
value={nameVal} onChange={e => setNameVal(e.target.value)}
|
|
|
|
|
onKeyDown={e => { if(e.key==='Enter') handleSaveName(); if(e.key==='Escape'){setEditName(false);setNameVal(u.name);} }}
|
|
|
|
|
autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
|
|
|
|
|
<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>
|
|
|
|
|
<div style={{ padding:'6px 12px 12px', display:'flex', alignItems:'center', gap:8 }}>
|
|
|
|
|
<button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button>
|
|
|
|
|
<div style={{ marginLeft:'auto', display:'flex', gap:8 }}>
|
|
|
|
|
{u.status === 'active' ? (
|
|
|
|
|
<button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button>
|
|
|
|
|
) : u.status === 'suspended' ? (
|
|
|
|
|
<button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button>
|
|
|
|
|
) : 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>
|
|
|
|
|
)}
|
|
|
|
|
@@ -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 [form, setForm] = useState({ name:'', email:'', password:'', role:'member' });
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
|
|
|
|
const handle = async () => {
|
|
|
|
|
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error');
|
|
|
|
|
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error');
|
|
|
|
|
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', 'error');
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try { await api.createUser(form); toast('User created', 'success'); setForm({ name:'', email:'', password:'', role:'member' }); onCreated(); }
|
|
|
|
|
catch(e) { toast(e.message, 'error'); }
|
|
|
|
|
finally { setSaving(false); }
|
|
|
|
|
const isEdit = !!user;
|
|
|
|
|
|
|
|
|
|
const [firstName, setFirstName] = useState(user?.first_name || '');
|
|
|
|
|
const [lastName, setLastName] = useState(user?.last_name || '');
|
|
|
|
|
const [email, setEmail] = useState(user?.email || '');
|
|
|
|
|
const [phone, setPhone] = useState(user?.phone || '');
|
|
|
|
|
const [role, setRole] = useState(user?.role || 'member');
|
|
|
|
|
const [isMinor, setIsMinor] = useState(!!user?.is_minor);
|
|
|
|
|
const [password, setPassword] = useState('');
|
|
|
|
|
const [pwEnabled, setPwEnabled] = useState(!isEdit);
|
|
|
|
|
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 (
|
|
|
|
|
<div style={{ maxWidth:560 }}>
|
|
|
|
|
<div style={{ display:'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap:12, marginBottom:12 }}>
|
|
|
|
|
<div className="flex-col gap-1">
|
|
|
|
|
<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>
|
|
|
|
|
<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} />
|
|
|
|
|
<div style={{ maxWidth: isMobile ? '100%' : 580 }}>
|
|
|
|
|
|
|
|
|
|
{/* Back + title */}
|
|
|
|
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:20 }}>
|
|
|
|
|
<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 className="flex-col gap-1">
|
|
|
|
|
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Email</label>
|
|
|
|
|
<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} />
|
|
|
|
|
<div>
|
|
|
|
|
{lbl('Last Name', true)}
|
|
|
|
|
<input className="input" placeholder="Smith"
|
|
|
|
|
value={lastName} onChange={e => setLastName(e.target.value)}
|
|
|
|
|
autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-col gap-1">
|
|
|
|
|
<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} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 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 className="flex-col gap-1">
|
|
|
|
|
<label className="text-sm font-medium" style={{ color:'var(--text-secondary)' }}>Role</label>
|
|
|
|
|
<select className="input" value={form.role} onChange={e => set('role')(e.target.value)}>
|
|
|
|
|
<div>
|
|
|
|
|
{lbl('App Role', true)}
|
|
|
|
|
<select className="input" value={role} onChange={e => setRole(e.target.value)}>
|
|
|
|
|
<option value="member">Member</option>
|
|
|
|
|
<option value="manager">Manager</option>
|
|
|
|
|
<option value="admin">Admin</option>
|
|
|
|
|
</select>
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Bulk Import Form ──────────────────────────────────────────────────────────
|
|
|
|
|
function BulkImportForm({ userPass, onCreated }) {
|
|
|
|
|
const toast = useToast();
|
|
|
|
|
const fileRef = useRef(null);
|
|
|
|
|
@@ -278,12 +364,13 @@ function BulkImportForm({ userPass, onCreated }) {
|
|
|
|
|
|
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
|
|
|
|
export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) {
|
|
|
|
|
const [users, setUsers] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [tab, setTab] = useState('users');
|
|
|
|
|
const [userPass, setUserPass] = useState('user@1234');
|
|
|
|
|
const [users, setUsers] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [loadError, setLoadError] = useState('');
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [view, setView] = useState('list'); // 'list' | 'create' | 'edit' | 'bulk'
|
|
|
|
|
const [editUser, setEditUser] = useState(null);
|
|
|
|
|
const [userPass, setUserPass] = useState('user@1234');
|
|
|
|
|
const [inputFocused, setInputFocused] = useState(false);
|
|
|
|
|
const onIF = () => setInputFocused(true);
|
|
|
|
|
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));
|
|
|
|
|
|
|
|
|
|
// ── Nav item helper (matches Schedule page style) ─────────────────────────
|
|
|
|
|
const navItem = (label, key) => (
|
|
|
|
|
<button key={key} onClick={() => setTab(key)}
|
|
|
|
|
const goList = () => { setView('list'); setEditUser(null); };
|
|
|
|
|
const goCreate = () => { setView('create'); setEditUser(null); };
|
|
|
|
|
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',
|
|
|
|
|
borderRadius:'var(--radius)', border:'none',
|
|
|
|
|
background: tab===key ? 'var(--primary-light)' : 'transparent',
|
|
|
|
|
color: tab===key ? 'var(--primary)' : 'var(--text-primary)',
|
|
|
|
|
cursor:'pointer', fontWeight: tab===key ? 600 : 400, fontSize:14, marginBottom:2 }}>
|
|
|
|
|
background: active ? 'var(--primary-light)' : 'transparent',
|
|
|
|
|
color: active ? 'var(--primary)' : 'var(--text-primary)',
|
|
|
|
|
cursor:'pointer', fontWeight: active ? 600 : 400, fontSize:14, marginBottom:2 }}>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const isFormView = view === 'create' || view === 'edit';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display:'flex', flex:1, overflow:'hidden', minHeight:0 }}>
|
|
|
|
|
|
|
|
|
|
{/* ── Left panel ── */}
|
|
|
|
|
{/* ── Desktop sidebar ── */}
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
<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' }}>
|
|
|
|
|
{/* Title — matches Schedule page */}
|
|
|
|
|
<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>
|
|
|
|
|
<span style={{ fontSize:16, fontWeight:700, color:'var(--text-primary)' }}>User Manager</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Tab navigation */}
|
|
|
|
|
<div className="section-label" style={{ marginBottom:6 }}>View</div>
|
|
|
|
|
{navItem(`All Users${!loading ? ` (${users.length})` : ''}`, 'users')}
|
|
|
|
|
{navItem('+ Create User', 'create')}
|
|
|
|
|
{navItem('Bulk Import CSV', 'bulk')}
|
|
|
|
|
{navItem(`All Users${!loading ? ` (${users.length})` : ''}`, view === 'list' || view === 'edit', goList)}
|
|
|
|
|
{navItem('+ Create User', view === 'create', goCreate)}
|
|
|
|
|
{navItem('Bulk Import CSV', view === 'bulk', goBulk)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ flex:1 }} />
|
|
|
|
|
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
|
|
|
|
@@ -346,27 +437,27 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|
|
|
|
{/* ── Right panel ── */}
|
|
|
|
|
<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 && (
|
|
|
|
|
<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>
|
|
|
|
|
<button className={`btn btn-sm ${tab==='users'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('users')}>All</button>
|
|
|
|
|
<button className={`btn btn-sm ${tab==='create'?'btn-primary':'btn-secondary'}`} onClick={() => setTab('create')}>+ Create</button>
|
|
|
|
|
<button className={`btn btn-sm ${!isFormView && view !== 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={goList}>All</button>
|
|
|
|
|
<button className={`btn btn-sm ${isFormView ? 'btn-primary' : 'btn-secondary'}`} onClick={goCreate}>+ Create</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
<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 }}>
|
|
|
|
|
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
|
|
|
|
onFocus={onIF} onBlur={onIB}
|
|
|
|
|
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
|
|
|
|
style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
|
|
|
|
</div>
|
|
|
|
|
{/* User list — bounded scroll */}
|
|
|
|
|
<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' }}>
|
|
|
|
|
{loading ? (
|
|
|
|
|
@@ -381,25 +472,37 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|
|
|
|
{search ? 'No users match your search.' : 'No users yet.'}
|
|
|
|
|
</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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{tab === 'create' && (
|
|
|
|
|
|
|
|
|
|
{/* CREATE / EDIT FORM */}
|
|
|
|
|
{isFormView && (
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
{tab === 'bulk' && (
|
|
|
|
|
|
|
|
|
|
{/* BULK IMPORT */}
|
|
|
|
|
{view === 'bulk' && (
|
|
|
|
|
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 72 : 16, overscrollBehavior:'contain' }}>
|
|
|
|
|
<BulkImportForm userPass={userPass} onCreated={load} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile footer — fixed, hidden when any input is focused (keyboard open) */}
|
|
|
|
|
{/* Mobile footer — fixed, hidden when keyboard is up */}
|
|
|
|
|
{isMobile && !inputFocused && (
|
|
|
|
|
<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} />
|
|
|
|
|
|