v0.7.4 bug fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.7.2}"
|
||||
VERSION="${1:-0.7.4}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user