Files
rosterchirp-dev/frontend/src/pages/UserManagerPage.jsx

515 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
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);
const rows = [], invalid = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (i === 0 && /^name\s*,/i.test(line)) continue;
const parts = line.split(',').map(p => p.trim());
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 24 comma-separated fields' }); continue; }
const [name, email, password, role] = parts;
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; }
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; }
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
}
return { rows, invalid };
}
// ── User Row (accordion list item) ───────────────────────────────────────────
function UserRow({ u, onUpdated, onEdit }) {
const toast = useToast();
const [open, setOpen] = useState(false);
const handleSuspend = async () => {
if (!confirm(`Suspend ${u.name}?`)) return;
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleActivate = async () => {
try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
const handleDelete = async () => {
if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error');
if (!confirm(`Delete ${u.name}?\n\nThis will:\n• Anonymise their account and free their email for re-use\n• Remove all their messages from conversations\n• Freeze any direct messages they were part of\n• Remove all their group memberships\n\nThis cannot be undone.`)) return;
try { await api.deleteUser(u.id); toast('User deleted', 'success'); onUpdated(); }
catch (e) { toast(e.message, 'error'); }
};
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<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" />
<div style={{ flex:1, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
<span style={{ fontWeight:600, fontSize:14 }}>{u.display_name || u.name}</span>
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
<span className={`role-badge role-${u.role}`}>{u.role}</span>
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
{!!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>
<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)' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && !u.is_default_admin && (
<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</button>
</div>
</div>
)}
</div>
);
}
// ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast();
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: 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>
{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>
{/* 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>
{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>
{/* 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);
const [csvFile, setCsvFile] = useState(null);
const [csvRows, setCsvRows] = useState([]);
const [csvInvalid, setCsvInvalid] = useState([]);
const [bulkResult, setBulkResult] = useState(null);
const [loading, setLoading] = useState(false);
const handleFile = e => {
const file = e.target.files?.[0]; if (!file) return;
setCsvFile(file); setBulkResult(null);
const reader = new FileReader();
reader.onload = ev => { const { rows, invalid } = parseCSV(ev.target.result); setCsvRows(rows); setCsvInvalid(invalid); };
reader.readAsText(file);
};
const handleImport = async () => {
if (!csvRows.length) return;
setLoading(true);
try {
const result = await api.bulkUsers(csvRows);
setBulkResult(result); setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
if (fileRef.current) fileRef.current.value = '';
onCreated();
} catch(e) { toast(e.message, 'error'); }
finally { setLoading(false); }
};
return (
<div style={{ maxWidth:560 }} className="flex-col gap-4">
<div className="card" style={{ background:'var(--background)', border:'1px dashed var(--border)' }}>
<p className="text-sm font-medium" style={{ marginBottom:6 }}>CSV Format</p>
<code style={{ fontSize:12, color:'var(--text-secondary)', display:'block', background:'var(--surface)', padding:8, borderRadius:4, border:'1px solid var(--border)', whiteSpace:'pre' }}>{"name,email,password,role\nJane Smith,jane@company.com,,member\nBob Jones,bob@company.com,TempPass1,admin"}</code>
<p className="text-xs" style={{ color:'var(--text-tertiary)', marginTop:8 }}>Name and email required. Blank password defaults to <strong>{userPass}</strong>, blank role defaults to member.</p>
</div>
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap' }}>
<label className="btn btn-secondary" style={{ cursor:'pointer', margin:0 }}>
Select CSV File<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display:'none' }} onChange={handleFile} />
</label>
{csvFile && <span className="text-sm" style={{ color:'var(--text-secondary)' }}>{csvFile.name}{csvRows.length > 0 && <span style={{ color:'var(--text-tertiary)', marginLeft:6 }}>({csvRows.length} valid)</span>}</span>}
{csvRows.length > 0 && <button className="btn btn-primary" onClick={handleImport} disabled={loading}>{loading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length!==1?'s':''}`}</button>}
</div>
{csvInvalid.length > 0 && (
<div style={{ background:'rgba(229,57,53,0.07)', border:'1px solid #e53935', borderRadius:'var(--radius)', padding:10 }}>
<p className="text-sm font-medium" style={{ color:'#e53935', marginBottom:6 }}>{csvInvalid.length} line{csvInvalid.length!==1?'s':''} skipped</p>
<div style={{ maxHeight:100, overflowY:'auto' }}>
{csvInvalid.map((e,i) => <div key={i} style={{ fontSize:12, padding:'2px 0', color:'var(--text-secondary)' }}><code style={{ fontSize:11 }}>{e.line}</code><span style={{ color:'#e53935', marginLeft:8 }}> {e.reason}</span></div>)}
</div>
</div>
)}
{bulkResult && (
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', padding:12 }}>
<p className="text-sm font-medium" style={{ color:'var(--success)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}> {bulkResult.created.length} user{bulkResult.created.length!==1?'s':''} created</p>
{bulkResult.skipped.length > 0 && (
<>
<p className="text-sm font-medium" style={{ color:'var(--text-secondary)', marginBottom:6 }}>{bulkResult.skipped.length} skipped:</p>
<div style={{ maxHeight:112, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{bulkResult.skipped.map((s,i) => (
<div key={i} style={{ display:'flex', justifyContent:'space-between', padding:'5px 10px', borderBottom: i<bulkResult.skipped.length-1?'1px solid var(--border)':'none', fontSize:13, gap:12 }}>
<span>{s.email}</span><span style={{ color:'var(--text-tertiary)', flexShrink:0 }}>{s.reason}</span>
</div>
))}
</div>
</>
)}
<button className="btn btn-secondary btn-sm" style={{ marginTop:10 }} onClick={() => setBulkResult(null)}>Dismiss</button>
</div>
)}
</div>
);
}
// ── 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 [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);
const load = useCallback(async () => {
setLoadError(''); setLoading(true);
try { const { users } = await api.getUsers(); setUsers(users || []); }
catch(e) { setLoadError(e.message || 'Failed to load users'); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {});
}, [load]);
const filtered = users
.filter(u =>
!search || u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.display_name?.toLowerCase().includes(search.toLowerCase()) ||
u.email?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name));
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: 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 }}>
{/* ── 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' }}>
<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>
<div className="section-label" style={{ marginBottom:6 }}>View</div>
{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} />
</div>
)}
{/* ── Right panel ── */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minWidth:0, background:'var(--background)' }}>
{/* 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 ${!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)' }}>
{/* LIST VIEW */}
{view === 'list' && (
<>
<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>
<div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<div style={{ background:'var(--surface)', borderRadius:'var(--radius)', boxShadow:'var(--shadow-sm)', overflow:'hidden' }}>
{loading ? (
<div style={{ padding:48, textAlign:'center' }}><div className="spinner" /></div>
) : loadError ? (
<div style={{ padding:32, textAlign:'center', color:'var(--error)' }}>
<div style={{ marginBottom:12 }}> {loadError}</div>
<button className="btn btn-secondary btn-sm" onClick={load}>Retry</button>
</div>
) : filtered.length === 0 ? (
<div style={{ padding:32, textAlign:'center', color:'var(--text-tertiary)', fontSize:14 }}>
{search ? 'No users match your search.' : 'No users yet.'}
</div>
) : (
filtered.map(u => <UserRow key={u.id} u={u} onUpdated={load} onEdit={goEdit} />)
)}
</div>
</div>
</>
)}
{/* CREATE / EDIT FORM */}
{isFormView && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<UserForm
user={view === 'edit' ? editUser : null}
userPass={userPass}
onDone={() => { load(); goList(); }}
onCancel={goList}
isMobile={isMobile}
onIF={onIF}
onIB={onIB}
/>
</div>
)}
{/* BULK IMPORT */}
{view === 'bulk' && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<BulkImportForm userPass={userPass} onCreated={load} />
</div>
)}
</div>
{/* 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} />
</div>
)}
</div>
</div>
);
}