From d2c157e8d03487b17565bbad4d474007df362b12 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 20 Mar 2026 22:28:14 -0400 Subject: [PATCH] v0.10.9 update ui settings --- backend/package.json | 2 +- backend/src/routes/users.js | 20 +++++ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/pages/Chat.jsx | 2 + frontend/src/pages/GroupManagerPage.jsx | 63 ++++++++++---- frontend/src/pages/UserManagerPage.jsx | 106 +++++++++++++----------- 7 files changed, 132 insertions(+), 65 deletions(-) diff --git a/backend/package.json b/backend/package.json index 9acefd6..ad85d71 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.10.8", + "version": "0.10.9", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index ee8440a..11a06b6 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -191,6 +191,8 @@ router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) if (!t) return res.status(404).json({ error: 'User not found' }); if (t.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' }); await exec(req.schema, "UPDATE users SET status='suspended', updated_at=NOW() WHERE id=$1", [t.id]); + // Clear active sessions so suspended user is immediately kicked + await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -205,7 +207,25 @@ router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]); if (!t) return res.status(404).json({ error: 'User not found' }); if (t.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' }); + + // Mark deleted await exec(req.schema, "UPDATE users SET status='deleted', updated_at=NOW() WHERE id=$1", [t.id]); + + // Remove from all chat group memberships + await exec(req.schema, 'DELETE FROM group_members WHERE user_id=$1', [t.id]); + + // Remove from all user groups (managed groups) + await exec(req.schema, 'DELETE FROM user_group_members WHERE user_id=$1', [t.id]); + + // Clear all active sessions so they cannot log in + await exec(req.schema, 'DELETE FROM active_sessions WHERE user_id=$1', [t.id]); + + // Remove push subscriptions + await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1', [t.id]); + + // Remove event availability responses + await exec(req.schema, 'DELETE FROM event_availability WHERE user_id=$1', [t.id]); + res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/build.sh b/build.sh index 8c1abeb..f0cfba2 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.10.8}" +VERSION="${1:-0.10.9}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index a69452d..65e58ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.10.8", + "version": "0.10.9", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index 2a5e153..4940ed1 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -353,6 +353,7 @@ export default function Chat() { /> {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} + {modal === 'branding' && setModal(null)} />} ); } @@ -377,6 +378,7 @@ export default function Chat() { /> {modal === 'profile' && setModal(null)} />} {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />} + {modal === 'branding' && setModal(null)} />} ); } diff --git a/frontend/src/pages/GroupManagerPage.jsx b/frontend/src/pages/GroupManagerPage.jsx index 50ccd79..6c58768 100644 --- a/frontend/src/pages/GroupManagerPage.jsx +++ b/frontend/src/pages/GroupManagerPage.jsx @@ -1,5 +1,18 @@ import { useState, useEffect, useCallback } from 'react'; import UserFooter from '../components/UserFooter.jsx'; + +// ── useKeyboardOpen — true when software keyboard is visible ───────────────── +function useKeyboardOpen() { + const [open, setOpen] = useState(false); + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const handler = () => setOpen(vv.height < window.innerHeight * 0.75); + vv.addEventListener('resize', handler); + return () => vv.removeEventListener('resize', handler); + }, []); + return open; +} import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from '../components/Avatar.jsx'; @@ -502,6 +515,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, const [allUsers, setAllUsers] = useState([]); const [allUserGroups, setAllUserGroups] = useState([]); const [refreshKey, setRefreshKey] = useState(0); + const keyboardOpen = useKeyboardOpen(); const onRefresh = () => setRefreshKey(k => k+1); useEffect(() => { @@ -509,12 +523,36 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, api.getUserGroups().then(({ groups }) => setAllUserGroups(groups)).catch(() => {}); }, [refreshKey]); + // Nav item helper — matches Schedule page style + const navItem = (label, key) => ( + + ); + return (
{/* ── Left panel (desktop only) ── */} {!isMobile && (
+
+ {/* Title — matches Schedule page */} +
+ + Group Manager +
+ {/* Tab navigation */} +
View
+ {navItem('User Groups', 'all')} + {navItem('Multi-Group DMs', 'dm')} + {navItem('U2U Restrictions', 'u2u')} +
@@ -523,20 +561,15 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, {/* ── Right panel ── */}
- {/* Header */} -
-
-
- - Group Manager -
-
- - - -
+ {/* Mobile tab bar — only shown on mobile */} + {isMobile && ( +
+ Groups + + +
-
+ )} {/* Content */}
@@ -545,8 +578,8 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp, {tab==='u2u' && }
- {/* Mobile footer — fixed at bottom, always visible */} - {isMobile && ( + {/* Mobile footer — hidden when keyboard is open */} + {isMobile && !keyboardOpen && (
diff --git a/frontend/src/pages/UserManagerPage.jsx b/frontend/src/pages/UserManagerPage.jsx index 9ecef3a..4a6f0b4 100644 --- a/frontend/src/pages/UserManagerPage.jsx +++ b/frontend/src/pages/UserManagerPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +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'; @@ -24,7 +24,6 @@ function parseCSV(text) { return { rows, invalid }; } -// ── User row (accordion) ────────────────────────────────────────────────────── function UserRow({ u, onUpdated }) { const toast = useToast(); const [open, setOpen] = useState(false); @@ -160,27 +159,20 @@ function UserRow({ u, onUpdated }) { ); } -// ── Create user form ────────────────────────────────────────────────────────── function CreateUserForm({ userPass, onCreated, isMobile }) { 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'); } + 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); } }; - return (
@@ -210,7 +202,6 @@ function CreateUserForm({ userPass, onCreated, isMobile }) { ); } -// ── Bulk import form ────────────────────────────────────────────────────────── function BulkImportForm({ userPass, onCreated }) { const toast = useToast(); const fileRef = useRef(null); @@ -219,7 +210,6 @@ function BulkImportForm({ userPass, onCreated }) { 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); @@ -227,7 +217,6 @@ function BulkImportForm({ userPass, onCreated }) { 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); @@ -239,20 +228,16 @@ function BulkImportForm({ userPass, onCreated }) { } catch(e) { toast(e.message, 'error'); } finally { setLoading(false); } }; - return (

CSV Format

{"name,email,password,role\nJane Smith,jane@company.com,,member\nBob Jones,bob@company.com,TempPass1,admin"} -

- Name and email required. Blank password defaults to {userPass}, blank role defaults to member. -

+

Name and email required. Blank password defaults to {userPass}, blank role defaults to member.

{csvFile && {csvFile.name}{csvRows.length > 0 && ({csvRows.length} valid)}} {csvRows.length > 0 && } @@ -287,6 +272,19 @@ function BulkImportForm({ userPass, onCreated }) { ); } +// ── useKeyboardOpen — true when software keyboard is visible ───────────────── +function useKeyboardOpen() { + const [open, setOpen] = useState(false); + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const handler = () => setOpen(vv.height < window.innerHeight * 0.75); + vv.addEventListener('resize', handler); + return () => vv.removeEventListener('resize', handler); + }, []); + return open; +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function UserManagerPage({ isMobile = false, onProfile, onHelp, onAbout }) { const [users, setUsers] = useState([]); @@ -295,21 +293,19 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o const [search, setSearch] = useState(''); const [tab, setTab] = useState('users'); const [userPass, setUserPass] = useState('user@1234'); - const [inputFocused, setInputFocused] = useState(false); + const keyboardOpen = useKeyboardOpen(); - const load = async () => { + const load = useCallback(async () => { setLoadError(''); setLoading(true); - try { - const { users } = await api.getUsers(); - setUsers(users || []); - } catch(e) { setLoadError(e.message || 'Failed to load users'); } + 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()) || @@ -317,12 +313,36 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o u.email?.toLowerCase().includes(search.toLowerCase()) ); + // ── Nav item helper (matches Schedule page style) ───────────────────────── + const navItem = (label, key) => ( + + ); + return (
- {/* ── Left panel (desktop only) — blank, reserved for future use ── */} + {/* ── Left panel ── */} {!isMobile && (
+
+ {/* Title — matches Schedule page */} +
+ + User Manager +
+ {/* Tab navigation */} +
View
+ {navItem(`All Users${!loading ? ` (${users.length})` : ''}`, 'users')} + {navItem('+ Create User', 'create')} + {navItem('Bulk Import CSV', 'bulk')} +
@@ -331,28 +351,20 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {/* ── Right panel ── */}
- {/* Header */} -
-
-
- - User Manager - {!loading && {users.length} user{users.length!==1?'s':''}} -
-
- - - {!isMobile && } -
+ {/* Mobile tab bar — only on mobile since desktop uses left panel */} + {isMobile && ( +
+ Users + +
-
+ )} {/* Content */} -
+
{tab === 'users' && ( <> setSearch(e.target.value)} - onFocus={() => setInputFocused(true)} onBlur={() => setInputFocused(false)} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ marginBottom:16, width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
@@ -377,8 +389,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o {tab === 'bulk' && }
- {/* Mobile footer — fixed at bottom, hidden when keyboard open */} - {isMobile && !inputFocused && ( + {/* Mobile footer — hidden when keyboard is open */} + {isMobile && !keyboardOpen && (