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
|
# 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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user