v0.7.4 bug fixes
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# 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)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.7.2",
|
"version": "0.7.4",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ const PORT = process.env.PORT || 3000;
|
|||||||
// Init DB
|
// Init DB
|
||||||
initDb();
|
initDb();
|
||||||
seedAdmin();
|
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
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ router.get('/', (req, res) => {
|
|||||||
...DEFAULTS,
|
...DEFAULTS,
|
||||||
...overrides,
|
...overrides,
|
||||||
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
|
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
|
// Never expose docker_image — removed from UI
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const bcrypt = require('bcryptjs');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, addUserToPublicGroups } = require('../models/db');
|
const { getDb, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
|
||||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
const avatarStorage = multer.diskStorage({
|
const avatarStorage = multer.diskStorage({
|
||||||
@@ -121,6 +121,13 @@ router.post('/', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
|
`).run(resolvedName, email, hash, role === 'admin' ? 'admin' : 'member');
|
||||||
|
|
||||||
addUserToPublicGroups(result.lastInsertRowid);
|
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);
|
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 });
|
res.json({ user });
|
||||||
});
|
});
|
||||||
@@ -151,8 +158,15 @@ router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
const resolvedName = resolveUniqueName(db, name);
|
const resolvedName = resolveUniqueName(db, name);
|
||||||
const pw = (u.password || '').trim() || defaultPw;
|
const pw = (u.password || '').trim() || defaultPw;
|
||||||
const hash = bcrypt.hashSync(pw, 10);
|
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);
|
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);
|
results.created.push(email);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.skipped.push({ email, reason: e.message });
|
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 (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' });
|
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);
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
4
build.sh
4
build.sh
@@ -11,9 +11,9 @@
|
|||||||
# REGISTRY=ghcr.io/yourname ./build.sh 1.2.0 push
|
# REGISTRY=ghcr.io/yourname ./build.sh 1.2.0 push
|
||||||
# REGISTRY=yourdockerhubuser ./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:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.7.2",
|
"version": "0.7.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -22,20 +22,19 @@ function BuiltWithValue({ value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutModal({ onClose }) {
|
export default function AboutModal({ onClose }) {
|
||||||
const [settings, setSettings] = useState({ app_name: 'jama', app_version: '' });
|
|
||||||
const [about, setAbout] = useState(null);
|
const [about, setAbout] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
|
||||||
fetch('/api/about')
|
fetch('/api/about')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(({ about }) => setAbout(about))
|
.then(({ about }) => setAbout(about))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appName = settings.app_name || 'jama';
|
// Always use the original app identity — not the user-customised settings name/logo
|
||||||
// Version always mirrors Settings window — from settings API (env var)
|
const appName = about?.default_app_name || 'jama';
|
||||||
const version = settings.app_version || about?.version || '';
|
const logoSrc = about?.default_logo || '/icons/jama.png';
|
||||||
|
const version = about?.version || '';
|
||||||
const a = about || {};
|
const a = about || {};
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -55,7 +54,7 @@ export default function AboutModal({ onClose }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="about-hero">
|
<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>
|
<h1 className="about-appname">{appName}</h1>
|
||||||
<p className="about-tagline">just another messaging app</p>
|
<p className="about-tagline">just another messaging app</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!group) { setMessages([]); return; }
|
if (!group) { setMessages([]); setPinnedMsgIds(new Set()); setPinCount(0); return; }
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -55,8 +55,40 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
})
|
})
|
||||||
.catch(e => toast(e.message, 'error'))
|
.catch(e => toast(e.message, 'error'))
|
||||||
.finally(() => setLoading(false));
|
.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]);
|
}, [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
|
// Socket events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !group) return;
|
if (!socket || !group) return;
|
||||||
|
|||||||
@@ -62,6 +62,60 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
|||||||
msg.group_owner_id === currentUser.id
|
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
|
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||||
if (isDeleted) return null;
|
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;
|
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 = {
|
const msgUser = {
|
||||||
id: msg.user_id,
|
id: msg.user_id,
|
||||||
name: msg.user_name,
|
name: msg.user_name,
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
||||||
<div className="flex-col gap-1">
|
<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 }))} />
|
<input className="input" type="text" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-col gap-1">
|
<div className="flex-col gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user