v0.9.3 added user feature to disable participation in private messages
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.9.2
|
||||
JAMA_VERSION=0.9.3
|
||||
|
||||
# Default admin credentials (used on FIRST RUN only)
|
||||
ADMIN_NAME=Admin User
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.9.2}"
|
||||
VERSION="${1:-0.9.3}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user