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
# 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)
ADMIN_NAME=Admin User

View File

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

View File

@@ -52,6 +52,7 @@ function initDb() {
about_me TEXT,
display_name TEXT,
hide_admin_tag INTEGER NOT NULL DEFAULT 0,
allow_dm INTEGER NOT NULL DEFAULT 1,
created_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');
} 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
try {
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) => {
const db = getDb();
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'
ORDER BY created_at ASC
`).all();
@@ -66,7 +66,7 @@ router.get('/search', authMiddleware, (req, res) => {
if (group && (group.type === 'private' || group.is_direct)) {
// Private group or direct message — only show members of this group
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
JOIN group_members gm ON gm.user_id = u.id AND gm.group_id = ?
WHERE u.status = 'active' AND u.id != ?
@@ -76,14 +76,14 @@ router.get('/search', authMiddleware, (req, res) => {
} else {
// Public group — all active users
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 ?)
LIMIT 10
`).all(req.user.id, `%${q}%`, `%${q}%`);
}
} else {
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 ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
@@ -247,7 +247,7 @@ router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
// Update own profile — display name must be unique (req 6)
router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = req.body;
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
const db = getDb();
if (displayName) {
const conflict = db.prepare(
@@ -255,9 +255,9 @@ router.patch('/me/profile', authMiddleware, (req, res) => {
).get(displayName, req.user.id);
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 = ?")
.run(displayName || null, aboutMe || null, hideAdminTag ? 1 : 0, 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);
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, 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, allow_dm FROM users WHERE id = ?').get(req.user.id);
res.json({ user });
});

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.2",
"version": "0.9.3",
"private": true,
"scripts": {
"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)' }}>
{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' }}>
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
<Avatar user={u} size="sm" />

View File

@@ -18,12 +18,13 @@ export default function ProfileModal({ onClose }) {
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password'
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
const handleSaveProfile = async () => {
if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true);
try {
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
updateUser(updated);
setSavedDisplayName(displayName);
toast('Profile updated', 'success');
@@ -149,6 +150,15 @@ export default function ProfileModal({ onClose }) {
Hide "Admin" tag next to my name in messages
</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}>
{loading ? 'Saving...' : 'Save Changes'}
</button>

View File

@@ -97,34 +97,46 @@ export default function UserProfilePopup({ user: profileUser, anchorEl, onClose,
</p>
)}
{!isSelf && onDirectMessage && (
<button
onClick={handleDM}
disabled={starting}
style={{
marginTop: 6,
width: '100%',
padding: '8px 0',
borderRadius: 'var(--radius)',
border: '1px solid var(--primary)',
background: 'transparent',
color: 'var(--primary)',
fontSize: 13,
fontWeight: 600,
cursor: starting ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
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>
profileUser.allow_dm === 0 ? (
<p style={{
marginTop: 8,
textAlign: 'center',
fontSize: 12,
color: 'var(--text-tertiary)',
fontStyle: 'italic',
}}>
DMs disabled by user
</p>
) : (
<button
onClick={handleDM}
disabled={starting}
style={{
marginTop: 6,
width: '100%',
padding: '8px 0',
borderRadius: 'var(--radius)',
border: '1px solid var(--primary)',
background: 'transparent',
color: 'var(--primary)',
fontSize: 13,
fontWeight: 600,
cursor: starting ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
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>
);

View File

@@ -63,7 +63,7 @@ export const api = {
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`),
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) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form);