v0.7.4 bug fixes

This commit is contained in:
2026-03-11 18:36:07 -04:00
parent 6ad9584ea9
commit 8202c838f5
11 changed files with 132 additions and 71 deletions

View File

@@ -7,7 +7,7 @@ TZ=UTC
# Copy this file to .env and customize
# Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.7.2
JAMA_VERSION=0.7.4
# Default admin credentials (used on FIRST RUN only)
ADMIN_NAME=Admin User

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.7.2",
"version": "0.7.4",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -21,7 +21,14 @@ const PORT = process.env.PORT || 3000;
// Init DB
initDb();
seedAdmin();
getOrCreateSupportGroup(); // Ensure Support group exists
// Ensure Support group exists and all admins are members
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
const db = getDb();
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const a of admins) insert.run(supportGroupId, a.id);
}
// Middleware
app.use(cors());

View File

@@ -29,6 +29,9 @@ router.get('/', (req, res) => {
...DEFAULTS,
...overrides,
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
// Always expose original app identity — not overrideable via about.json or settings
default_app_name: 'jama',
default_logo: '/icons/jama.png',
};
// Never expose docker_image — removed from UI

View File

@@ -3,7 +3,7 @@ const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { getDb, addUserToPublicGroups } = require('../models/db');
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const avatarStorage = multer.diskStorage({
@@ -121,6 +121,13 @@ router.post('/', authMiddleware, adminMiddleware, (req, res) => {
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(result.lastInsertRowid);
// Admin users are automatically added to the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, result.lastInsertRowid);
}
}
const user = db.prepare('SELECT id, name, email, role, status, must_change_password, created_at FROM users WHERE id = ?').get(result.lastInsertRowid);
res.json({ user });
});
@@ -151,8 +158,15 @@ router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
const resolvedName = resolveUniqueName(db, name);
const pw = (u.password || '').trim() || defaultPw;
const hash = bcrypt.hashSync(pw, 10);
const r = insertUser.run(resolvedName, email, hash, u.role === 'admin' ? 'admin' : 'member');
const newRole = u.role === 'admin' ? 'admin' : 'member';
const r = insertUser.run(resolvedName, email, hash, newRole);
addUserToPublicGroups(r.lastInsertRowid);
if (newRole === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, r.lastInsertRowid);
}
}
results.created.push(email);
} catch (e) {
results.skipped.push({ email, reason: e.message });
@@ -184,6 +198,13 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot modify default admin role' });
if (!['member', 'admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
// If promoted to admin, ensure they're in the Support group
if (role === 'admin') {
const supportGroupId = getOrCreateSupportGroup();
if (supportGroupId) {
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(supportGroupId, target.id);
}
}
res.json({ success: true });
});

View File

@@ -11,9 +11,9 @@
# REGISTRY=ghcr.io/yourname ./build.sh 1.2.0 push
# REGISTRY=yourdockerhubuser ./build.sh 1.2.0 push
# ─────────────────────────────────────────────────────────────
set -euo pipefail
set -euo pipefail
VERSION="${1:-0.7.2}"
VERSION="${1:-0.7.4}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.7.2",
"version": "0.7.4",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -22,20 +22,19 @@ function BuiltWithValue({ value }) {
}
export default function AboutModal({ onClose }) {
const [settings, setSettings] = useState({ app_name: 'jama', app_version: '' });
const [about, setAbout] = useState(null);
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
fetch('/api/about')
.then(r => r.json())
.then(({ about }) => setAbout(about))
.catch(() => {});
}, []);
const appName = settings.app_name || 'jama';
// Version always mirrors Settings window — from settings API (env var)
const version = settings.app_version || about?.version || '';
// Always use the original app identity — not the user-customised settings name/logo
const appName = about?.default_app_name || 'jama';
const logoSrc = about?.default_logo || '/icons/jama.png';
const version = about?.version || '';
const a = about || {};
const rows = [
@@ -55,7 +54,7 @@ export default function AboutModal({ onClose }) {
</button>
<div className="about-hero">
<img src="/icons/jama.png" alt="jama" className="about-logo" />
<img src={logoSrc} alt={appName} className="about-logo" />
<h1 className="about-appname">{appName}</h1>
<p className="about-tagline">just another messaging app</p>
</div>

View File

@@ -43,7 +43,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
}, []);
useEffect(() => {
if (!group) { setMessages([]); return; }
if (!group) { setMessages([]); setPinnedMsgIds(new Set()); setPinCount(0); return; }
setMessages([]);
setHasMore(false);
setLoading(true);
@@ -55,8 +55,40 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
})
.catch(e => toast(e.message, 'error'))
.finally(() => setLoading(false));
// Load pinned messages for DMs
if (group.is_direct) {
api.getPinnedMessages(group.id)
.then(({ pinned, count }) => {
setPinnedMsgIds(new Set(pinned.map(p => p.id)));
setPinCount(count);
})
.catch(() => {});
} else {
setPinnedMsgIds(new Set());
setPinCount(0);
}
}, [group?.id]);
const handlePinMessage = async (msgId) => {
try {
const { count } = await api.pinMessage(msgId);
setPinnedMsgIds(prev => new Set([...prev, msgId]));
setPinCount(count);
} catch (e) {
toast(e.message || 'Could not pin message', 'error');
}
};
const handleUnpinMessage = async (msgId) => {
try {
const { count } = await api.unpinMessage(msgId);
setPinnedMsgIds(prev => { const n = new Set(prev); n.delete(msgId); return n; });
setPinCount(count);
} catch (e) {
toast(e.message || 'Could not unpin message', 'error');
}
};
// Socket events
useEffect(() => {
if (!socket || !group) return;

View File

@@ -62,6 +62,60 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
msg.group_owner_id === currentUser.id
);
// Close emoji picker when clicking outside
useEffect(() => {
if (!showEmojiPicker) return;
const handler = (e) => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
setShowEmojiPicker(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showEmojiPicker]);
// Close options menu on outside click
useEffect(() => {
if (!showOptionsMenu) return;
const close = (e) => {
if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) {
setShowOptionsMenu(false);
}
};
document.addEventListener('mousedown', close);
document.addEventListener('touchstart', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('touchstart', close);
};
}, [showOptionsMenu]);
const handleReact = (emoji) => {
onReact(msg.id, emoji);
setShowEmojiPicker(false);
};
const handleCopy = () => {
if (!msg.content) return;
navigator.clipboard.writeText(msg.content).catch(() => {});
};
const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect();
setPickerOpensDown(rect.top < 400);
}
setShowEmojiPicker(p => !p);
};
// Long press for mobile action menu
const handleTouchStart = () => {
longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500);
};
const handleTouchEnd = () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
};
// Deleted messages are filtered out by ChatWindow, but guard here too
if (isDeleted) return null;
@@ -85,61 +139,6 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true;
}
// Close emoji picker when clicking outside
useEffect(() => {
if (!showEmojiPicker) return;
const handler = (e) => {
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
setShowEmojiPicker(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showEmojiPicker]);
const handleReact = (emoji) => {
onReact(msg.id, emoji);
setShowEmojiPicker(false);
};
const handleCopy = () => {
if (!msg.content) return;
navigator.clipboard.writeText(msg.content).catch(() => {});
};
const handleTogglePicker = () => {
if (!showEmojiPicker && wrapperRef.current) {
// If the message is in the top 400px of viewport, open picker downward
const rect = wrapperRef.current.getBoundingClientRect();
setPickerOpensDown(rect.top < 400);
}
setShowEmojiPicker(p => !p);
};
// Long press for mobile action menu
const handleTouchStart = () => {
longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500);
};
const handleTouchEnd = () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
};
// Close options menu on outside click
useEffect(() => {
if (!showOptionsMenu) return;
const close = (e) => {
if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) {
setShowOptionsMenu(false);
}
};
document.addEventListener('mousedown', close);
document.addEventListener('touchstart', close);
return () => {
document.removeEventListener('mousedown', close);
document.removeEventListener('touchstart', close);
};
}, [showOptionsMenu]);
const msgUser = {
id: msg.user_id,
name: msg.user_name,

View File

@@ -327,7 +327,7 @@ export default function UserManagerModal({ onClose }) {
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
<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 = default)</span></label>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(blank = {userPass || 'USER_PASS'})</span></label>
<input className="input" type="text" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
</div>
<div className="flex-col gap-1">