v0.9.3 added user feature to disable participation in private messages

This commit is contained in:
2026-03-13 16:25:33 -04:00
parent 5301d8a525
commit 62b89b6548
10 changed files with 72 additions and 43 deletions

View File

@@ -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.9.2 JAMA_VERSION=0.9.3
# Default admin credentials (used on FIRST RUN only) # Default admin credentials (used on FIRST RUN only)
ADMIN_NAME=Admin User ADMIN_NAME=Admin User

View File

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

View File

@@ -52,6 +52,7 @@ function initDb() {
about_me TEXT, about_me TEXT,
display_name TEXT, display_name TEXT,
hide_admin_tag INTEGER NOT NULL DEFAULT 0, hide_admin_tag INTEGER NOT NULL DEFAULT 0,
allow_dm INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -170,6 +171,12 @@ function initDb() {
console.log('[DB] Migration: added hide_admin_tag column'); console.log('[DB] Migration: added hide_admin_tag column');
} catch (e) { /* column already exists */ } } catch (e) { /* column already exists */ }
// Migration: add allow_dm if upgrading from older version
try {
db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1");
console.log('[DB] Migration: added allow_dm column');
} catch (e) { /* column already exists */ }
// Migration: replace single-session active_sessions with per-device version // Migration: replace single-session active_sessions with per-device version
try { try {
const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name); const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name);

View File

@@ -49,7 +49,7 @@ function getDefaultPassword(db) {
router.get('/', authMiddleware, adminMiddleware, (req, res) => { router.get('/', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb(); const db = getDb();
const users = db.prepare(` const users = db.prepare(`
SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, created_at, last_online SELECT id, name, email, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, allow_dm, created_at, last_online
FROM users WHERE status != 'deleted' FROM users WHERE status != 'deleted'
ORDER BY created_at ASC ORDER BY created_at ASC
`).all(); `).all();
@@ -66,7 +66,7 @@ router.get('/search', authMiddleware, (req, res) => {
if (group && (group.type === 'private' || group.is_direct)) { if (group && (group.type === 'private' || group.is_direct)) {
// Private group or direct message — only show members of this group // Private group or direct message — only show members of this group
users = db.prepare(` users = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status, u.hide_admin_tag, u.allow_dm
FROM users u FROM users u
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ? JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
WHERE u.status = 'active' AND u.id != ? WHERE u.status = 'active' AND u.id != ?
@@ -76,14 +76,14 @@ router.get('/search', authMiddleware, (req, res) => {
} else { } else {
// Public group — all active users // Public group — all active users
users = db.prepare(` users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?) WHERE status = 'active' AND id != ? AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10 LIMIT 10
`).all(req.user.id, `%${q}%`, `%${q}%`); `).all(req.user.id, `%${q}%`, `%${q}%`);
} }
} else { } else {
users = db.prepare(` users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users SELECT id, name, display_name, avatar, role, status, hide_admin_tag, allow_dm FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?) WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10 LIMIT 10
`).all(`%${q}%`, `%${q}%`); `).all(`%${q}%`, `%${q}%`);
@@ -247,7 +247,7 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
// Update own profile — display name must be unique (req 6) // Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => { router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = req.body; const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const db = getDb(); const db = getDb();
if (displayName) { if (displayName) {
const conflict = db.prepare( const conflict = db.prepare(
@@ -255,9 +255,9 @@ router.patch('/me/profile', authMiddleware, (req, res) => {
).get(displayName, req.user.id); ).get(displayName, req.user.id);
if (conflict) return res.status(400).json({ error: 'Display name already in use' }); if (conflict) return res.status(400).json({ error: 'Display name already in use' });
} }
db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE users SET display_name = ?, about_me = ?, hide_admin_tag = ?, allow_dm = ?, updated_at = datetime('now') WHERE id = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, req.user.id); .run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, allowDm === false ? 0 : 1, req.user.id);
const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT id, name, email, role, status, avatar, about_me, display_name, hide_admin_tag, allow_dm FROM users WHERE id = ?').get(req.user.id);
res.json({ user }); res.json({ user });
}); });

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.9.2}" VERSION="${1:-0.9.3}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="jama"

View File

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

View File

@@ -142,7 +142,7 @@ export default function NewChatModal({ onClose, onCreated }) {
)} )}
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}> <div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{users.filter(u => u.id !== user.id).map(u => ( {users.filter(u => u.id !== user.id && u.allow_dm !== 0).map(u => (
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}> <label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} /> <input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
<Avatar user={u} size="sm" /> <Avatar user={u} size="sm" />

View File

@@ -18,12 +18,13 @@ export default function ProfileModal({ onClose }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password' const [tab, setTab] = useState('profile'); // 'profile' | 'password'
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag); const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
if (displayNameWarning) return toast('Display name is already in use', 'error'); if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true); setLoading(true);
try { try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag }); const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
updateUser(updated); updateUser(updated);
setSavedDisplayName(displayName); setSavedDisplayName(displayName);
toast('Profile updated', 'success'); toast('Profile updated', 'success');
@@ -149,6 +150,15 @@ export default function ProfileModal({ onClose }) {
Hide "Admin" tag next to my name in messages Hide "Admin" tag next to my name in messages
</label> </label>
)} )}
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
<input
type="checkbox"
checked={allowDm}
onChange={e => setAllowDm(e.target.checked)}
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
/>
Allow others to send me direct messages
</label>
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}> <button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'} {loading ? 'Saving...' : 'Save Changes'}
</button> </button>

View File

@@ -97,34 +97,46 @@ export default function UserProfilePopup({ user: profileUser, anchorEl, onClose,
</p> </p>
)} )}
{!isSelf && onDirectMessage && ( {!isSelf && onDirectMessage && (
<button profileUser.allow_dm === 0 ? (
onClick={handleDM} <p style={{
disabled={starting} marginTop: 8,
style={{ textAlign: 'center',
marginTop: 6, fontSize: 12,
width: '100%', color: 'var(--text-tertiary)',
padding: '8px 0', fontStyle: 'italic',
borderRadius: 'var(--radius)', }}>
border: '1px solid var(--primary)', DMs disabled by user
background: 'transparent', </p>
color: 'var(--primary)', ) : (
fontSize: 13, <button
fontWeight: 600, onClick={handleDM}
cursor: starting ? 'default' : 'pointer', disabled={starting}
display: 'flex', style={{
alignItems: 'center', marginTop: 6,
justifyContent: 'center', width: '100%',
gap: 6, padding: '8px 0',
transition: 'background var(--transition), color var(--transition)', borderRadius: 'var(--radius)',
}} border: '1px solid var(--primary)',
onMouseEnter={e => { e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }} background: 'transparent',
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }} color: 'var(--primary)',
> fontSize: 13,
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> fontWeight: 600,
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> cursor: starting ? 'default' : 'pointer',
</svg> display: 'flex',
{starting ? 'Opening...' : 'Direct Message'} alignItems: 'center',
</button> justifyContent: 'center',
gap: 6,
transition: 'background var(--transition), color var(--transition)',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{starting ? 'Opening...' : 'Direct Message'}
</button>
)
)} )}
</div> </div>
); );

View File

@@ -63,7 +63,7 @@ export const api = {
activateUser: (id) => req('PATCH', `/users/${id}/activate`), activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`), deleteUser: (id) => req('DELETE', `/users/${id}`),
checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`), checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`),
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag } updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag, allowDm }
uploadAvatar: (file) => { uploadAvatar: (file) => {
const form = new FormData(); form.append('avatar', file); const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form); return req('POST', '/users/me/avatar', form);