Initial Push to GIT

This commit is contained in:
2026-03-06 11:49:48 -05:00
parent 43cba70fad
commit ee68c4704f
16 changed files with 1860 additions and 1 deletions

102
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,102 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router();
const { getDb, getOrCreateSupportGroup } = require('../models/db');
const { generateToken, authMiddleware } = require('../middleware/auth');
// Login
router.post('/login', (req, res) => {
const { email, password, rememberMe } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.status === 'suspended') {
const adminUser = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
return res.status(403).json({
error: 'suspended',
adminEmail: adminUser?.email
});
}
if (user.status === 'deleted') return res.status(403).json({ error: 'Account not found' });
const valid = bcrypt.compareSync(password, user.password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user.id);
const { password: _, ...userSafe } = user;
res.json({
token,
user: userSafe,
mustChangePassword: !!user.must_change_password,
rememberMe: !!rememberMe
});
});
// Change password
router.post('/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(currentPassword, user.password)) {
return res.status(400).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 0, updated_at = datetime('now') WHERE id = ?").run(hash, req.user.id);
res.json({ success: true });
});
// Get current user
router.get('/me', authMiddleware, (req, res) => {
const { password, ...user } = req.user;
res.json({ user });
});
// Logout (client-side token removal, but we can track it)
router.post('/logout', authMiddleware, (req, res) => {
res.json({ success: true });
});
// Public support contact form — no auth required
router.post('/support', (req, res) => {
const { name, email, message } = req.body;
if (!name?.trim() || !email?.trim() || !message?.trim()) {
return res.status(400).json({ error: 'All fields are required' });
}
if (message.trim().length > 2000) {
return res.status(400).json({ error: 'Message too long (max 2000 characters)' });
}
const db = getDb();
// Get or create the Support group
const groupId = getOrCreateSupportGroup();
if (!groupId) return res.status(500).json({ error: 'Support group unavailable' });
// Find a system/admin user to post as (default admin)
const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
if (!admin) return res.status(500).json({ error: 'No admin configured' });
// Format the support message
const content = `📬 **Support Request**
**Name:** ${name.trim()}
**Email:** ${email.trim()}
${message.trim()}`;
db.prepare(`
INSERT INTO messages (group_id, user_id, content, type)
VALUES (?, ?, ?, 'text')
`).run(groupId, admin.id, content);
console.log(`[Support] Message from ${email} posted to Support group`);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,153 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Get all groups for current user
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const userId = req.user.id;
// Public groups (all users are members)
const publicGroups = db.prepare(`
SELECT g.*,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at
FROM groups g
WHERE g.type = 'public'
ORDER BY g.is_default DESC, g.name ASC
`).all();
// Private groups (user is a member)
const privateGroups = db.prepare(`
SELECT g.*,
u.name as owner_name,
(SELECT COUNT(*) FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0) as message_count,
(SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message,
(SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at
FROM groups g
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
LEFT JOIN users u ON g.owner_id = u.id
WHERE g.type = 'private'
ORDER BY last_message_at DESC NULLS LAST
`).all(userId);
res.json({ publicGroups, privateGroups });
});
// Create group
router.post('/', authMiddleware, (req, res) => {
const { name, type, memberIds, isReadonly } = req.body;
const db = getDb();
if (type === 'public' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only admins can create public groups' });
}
const result = db.prepare(`
INSERT INTO groups (name, type, owner_id, is_readonly)
VALUES (?, ?, ?, ?)
`).run(name, type || 'private', req.user.id, isReadonly ? 1 : 0);
const groupId = result.lastInsertRowid;
if (type === 'public') {
// Add all users to public group
const allUsers = db.prepare("SELECT id FROM users WHERE status = 'active'").all();
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const u of allUsers) insert.run(groupId, u.id);
} else {
// Add creator
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(groupId, req.user.id);
// Add other members
if (memberIds && memberIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
for (const uid of memberIds) insert.run(groupId, uid);
}
}
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
res.json({ group });
});
// Rename group
router.patch('/:id/rename', authMiddleware, (req, res) => {
const { name } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot rename default group' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can rename public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can rename private group' });
}
db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name, group.id);
res.json({ success: true });
});
// Get group members
router.get('/:id/members', authMiddleware, (req, res) => {
const db = getDb();
const members = db.prepare(`
SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
FROM group_members gm
JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = ?
ORDER BY u.name ASC
`).all(req.params.id);
res.json({ members });
});
// Add member to private group
router.post('/:id/members', authMiddleware, (req, res) => {
const { userId } = req.body;
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot manually add members to public groups' });
if (group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner can add members' });
}
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(group.id, userId);
res.json({ success: true });
});
// Leave private group
router.delete('/:id/leave', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.type === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, req.user.id);
res.json({ success: true });
});
// Admin take ownership of private group
router.post('/:id/take-ownership', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE groups SET owner_id = ?, updated_at = datetime('now') WHERE id = ?").run(req.user.id, req.params.id);
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(req.params.id, req.user.id);
res.json({ success: true });
});
// Delete group (admin or private group owner)
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (group.is_default) return res.status(403).json({ error: 'Cannot delete default group' });
if (group.type === 'public' && req.user.role !== 'admin') return res.status(403).json({ error: 'Only admins can delete public groups' });
if (group.type === 'private' && group.owner_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Only owner or admin can delete private groups' });
}
db.prepare('DELETE FROM groups WHERE id = ?').run(group.id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -0,0 +1,175 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
const imgStorage = multer.diskStorage({
destination: '/app/uploads/images',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `img_${Date.now()}_${Math.random().toString(36).substr(2, 6)}${ext}`);
}
});
const uploadImage = multer({
storage: imgStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
function getUserForMessage(db, userId) {
return db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ?').get(userId);
}
function canAccessGroup(db, groupId, userId) {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId);
if (!group) return null;
if (group.type === 'public') return group;
const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId);
if (!member) return null;
return group;
}
// Get messages for group
router.get('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
const { before, limit = 50 } = req.query;
let query = `
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me,
rm.content as reply_content, rm.image_url as reply_image_url,
ru.name as reply_user_name, ru.display_name as reply_user_display_name,
rm.is_deleted as reply_is_deleted
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.group_id = ?
`;
const params = [req.params.groupId];
if (before) {
query += ' AND m.id < ?';
params.push(before);
}
query += ' ORDER BY m.created_at DESC LIMIT ?';
params.push(parseInt(limit));
const messages = db.prepare(query).all(...params);
// Get reactions for these messages
for (const msg of messages) {
msg.reactions = db.prepare(`
SELECT r.emoji, r.user_id, u.name as user_name
FROM reactions r JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(msg.id);
}
res.json({ messages: messages.reverse() });
});
// Send message
router.post('/group/:groupId', authMiddleware, (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is read-only' });
const { content, replyToId, linkPreview } = req.body;
if (!content?.trim() && !req.body.imageUrl) return res.status(400).json({ error: 'Message cannot be empty' });
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, reply_to_id, link_preview)
VALUES (?, ?, ?, ?, ?)
`).run(req.params.groupId, req.user.id, content?.trim() || null, replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role,
rm.content as reply_content, ru.name as reply_user_name, ru.display_name as reply_user_display_name
FROM messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
res.json({ message });
});
// Upload image message
router.post('/group/:groupId/image', authMiddleware, uploadImage.single('image'), (req, res) => {
const db = getDb();
const group = canAccessGroup(db, req.params.groupId, req.user.id);
if (!group) return res.status(403).json({ error: 'Access denied' });
if (group.is_readonly && req.user.role !== 'admin') return res.status(403).json({ error: 'Read-only group' });
if (!req.file) return res.status(400).json({ error: 'No image' });
const imageUrl = `/uploads/images/${req.file.filename}`;
const { content, replyToId } = req.body;
const result = db.prepare(`
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id)
VALUES (?, ?, ?, ?, 'image', ?)
`).run(req.params.groupId, req.user.id, content || null, imageUrl, replyToId || null);
const message = db.prepare(`
SELECT m.*,
u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role
FROM messages m JOIN users u ON m.user_id = u.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
message.reactions = [];
res.json({ message });
});
// Delete message
router.delete('/:id', authMiddleware, (req, res) => {
const db = getDb();
const message = db.prepare('SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_readonly FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ?').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id ||
(req.user.role === 'admin' && message.group_type === 'public') ||
(message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });
db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(message.id);
res.json({ success: true, messageId: message.id });
});
// Add/toggle reaction
router.post('/:id/reactions', authMiddleware, (req, res) => {
const { emoji } = req.body;
const db = getDb();
const message = db.prepare('SELECT * FROM messages WHERE id = ? AND is_deleted = 0').get(req.params.id);
if (!message) return res.status(404).json({ error: 'Message not found' });
// Check if user's message is from deleted/suspended user
const msgUser = db.prepare('SELECT status FROM users WHERE id = ?').get(message.user_id);
if (msgUser.status !== 'active') return res.status(400).json({ error: 'Cannot react to this message' });
const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(message.id, req.user.id, emoji);
if (existing) {
db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id);
res.json({ removed: true, emoji });
} else {
db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(message.id, req.user.id, emoji);
res.json({ added: true, emoji });
}
});
module.exports = router;

View File

@@ -0,0 +1,90 @@
const express = require('express');
const webpush = require('web-push');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
// Get or generate VAPID keys stored in settings
function getVapidKeys() {
const db = getDb();
let pub = db.prepare("SELECT value FROM settings WHERE key = 'vapid_public'").get();
let priv = db.prepare("SELECT value FROM settings WHERE key = 'vapid_private'").get();
if (!pub?.value || !priv?.value) {
const keys = webpush.generateVAPIDKeys();
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
ins.run('vapid_public', keys.publicKey, keys.publicKey);
ins.run('vapid_private', keys.privateKey, keys.privateKey);
console.log('[Push] Generated new VAPID keys');
return keys;
}
return { publicKey: pub.value, privateKey: priv.value };
}
function initWebPush() {
const keys = getVapidKeys();
webpush.setVapidDetails(
'mailto:admin@teamchat.local',
keys.publicKey,
keys.privateKey
);
return keys.publicKey;
}
// Export for use in index.js
let vapidPublicKey = null;
function getVapidPublicKey() {
if (!vapidPublicKey) vapidPublicKey = initWebPush();
return vapidPublicKey;
}
// Send a push notification to all subscriptions for a user
async function sendPushToUser(userId, payload) {
const db = getDb();
getVapidPublicKey(); // ensure webpush is configured
const subs = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
for (const sub of subs) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify(payload)
);
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Subscription expired — remove it
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}
}
// GET /api/push/vapid-public — returns VAPID public key for client subscription
router.get('/vapid-public', (req, res) => {
res.json({ publicKey: getVapidPublicKey() });
});
// POST /api/push/subscribe — save push subscription for current user
router.post('/subscribe', authMiddleware, (req, res) => {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Invalid subscription' });
}
const db = getDb();
db.prepare(`
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET user_id = ?, p256dh = ?, auth = ?
`).run(req.user.id, endpoint, keys.p256dh, keys.auth, req.user.id, keys.p256dh, keys.auth);
res.json({ success: true });
});
// POST /api/push/unsubscribe — remove subscription
router.post('/unsubscribe', authMiddleware, (req, res) => {
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint);
res.json({ success: true });
});
module.exports = { router, sendPushToUser, getVapidPublicKey };

View File

@@ -0,0 +1,125 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
// Generic icon storage factory
function makeIconStorage(prefix) {
return multer.diskStorage({
destination: '/app/uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${prefix}_${Date.now()}${ext}`);
}
});
}
const iconUploadOpts = {
limits: { fileSize: 1 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
};
const uploadLogo = multer({ storage: makeIconStorage('logo'), ...iconUploadOpts });
const uploadNewChat = multer({ storage: makeIconStorage('newchat'), ...iconUploadOpts });
const uploadGroupInfo = multer({ storage: makeIconStorage('groupinfo'), ...iconUploadOpts });
// Get public settings (accessible by all)
router.get('/', (req, res) => {
const db = getDb();
const settings = db.prepare('SELECT key, value FROM settings').all();
const obj = {};
for (const s of settings) obj[s.key] = s.value;
const admin = db.prepare('SELECT email FROM users WHERE is_default_admin = 1').get();
if (admin) obj.admin_email = admin.email;
// Expose app version from Docker build arg env var
obj.app_version = process.env.TEAMCHAT_VERSION || 'dev';
res.json({ settings: obj });
});
// Update app name (admin)
router.patch('/app-name', authMiddleware, adminMiddleware, (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(name.trim());
res.json({ success: true, name: name.trim() });
});
// Upload app logo (admin) — also generates 192x192 and 512x512 PWA icons
router.post('/logo', authMiddleware, adminMiddleware, uploadLogo.single('logo'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const logoUrl = `/uploads/logos/${req.file.filename}`;
const srcPath = req.file.path;
try {
// Generate PWA icons from the uploaded logo
const icon192Path = '/app/uploads/logos/pwa-icon-192.png';
const icon512Path = '/app/uploads/logos/pwa-icon-512.png';
await sharp(srcPath)
.resize(192, 192, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon192Path);
await sharp(srcPath)
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(icon512Path);
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
// Store the PWA icon paths so the manifest can reference them
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_192', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-192.png', '/uploads/logos/pwa-icon-192.png');
db.prepare("INSERT INTO settings (key, value) VALUES ('pwa_icon_512', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run('/uploads/logos/pwa-icon-512.png', '/uploads/logos/pwa-icon-512.png');
res.json({ logoUrl });
} catch (err) {
console.error('[Logo] Failed to generate PWA icons:', err.message);
// Still save the logo even if icon generation fails
const db = getDb();
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'logo_url'").run(logoUrl);
res.json({ logoUrl });
}
});
// Upload New Chat icon (admin)
router.post('/icon-newchat', authMiddleware, adminMiddleware, uploadNewChat.single('icon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_newchat', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
});
// Upload Group Info icon (admin)
router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.single('icon'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const iconUrl = `/uploads/logos/${req.file.filename}`;
const db = getDb();
db.prepare("INSERT INTO settings (key, value) VALUES ('icon_groupinfo', ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')")
.run(iconUrl, iconUrl);
res.json({ iconUrl });
});
// Reset all settings to defaults (admin)
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const originalName = process.env.APP_NAME || 'TeamChat';
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName);
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run();
db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512')").run();
res.json({ success: true });
});
module.exports = router;

177
backend/src/routes/users.js Normal file
View File

@@ -0,0 +1,177 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const { getDb, addUserToPublicGroups } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const avatarStorage = multer.diskStorage({
destination: '/app/uploads/avatars',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar_${req.user.id}_${Date.now()}${ext}`);
}
});
const uploadAvatar = multer({
storage: avatarStorage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Images only'));
}
});
// List users (admin)
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
FROM users WHERE status != 'deleted'
ORDER BY created_at ASC
`).all();
res.json({ users });
});
// Get single user profile (public-ish for mentions)
router.get('/search', authMiddleware, (req, res) => {
const { q } = req.query;
const db = getDb();
const users = db.prepare(`
SELECT id, name, display_name, avatar, role, status, hide_admin_tag FROM users
WHERE status = 'active' AND (name LIKE ? OR display_name LIKE ?)
LIMIT 10
`).all(`%${q}%`, `%${q}%`);
res.json({ users });
});
// Create user (admin)
router.post('/', authMiddleware, adminMiddleware, (req, res) => {
const { name, email, password, role } = req.body;
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, password required' });
const db = getDb();
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (exists) return res.status(400).json({ error: 'Email already in use' });
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`).run(name, email, hash, role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(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 });
});
// Bulk create users via CSV data
router.post('/bulk', authMiddleware, adminMiddleware, (req, res) => {
const { users } = req.body; // array of {name, email, password, role}
const db = getDb();
const results = { created: [], errors: [] };
const insertUser = db.prepare(`
INSERT INTO users (name, email, password, role, status, must_change_password)
VALUES (?, ?, ?, ?, 'active', 1)
`);
const transaction = db.transaction((users) => {
for (const u of users) {
if (!u.name || !u.email || !u.password) {
results.errors.push({ email: u.email, error: 'Missing required fields' });
continue;
}
const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(u.email);
if (exists) {
results.errors.push({ email: u.email, error: 'Email already exists' });
continue;
}
try {
const hash = bcrypt.hashSync(u.password, 10);
const r = insertUser.run(u.name, u.email, hash, u.role === 'admin' ? 'admin' : 'member');
addUserToPublicGroups(r.lastInsertRowid);
results.created.push(u.email);
} catch (e) {
results.errors.push({ email: u.email, error: e.message });
}
}
});
transaction(users);
res.json(results);
});
// Update user role (admin)
router.patch('/:id/role', authMiddleware, adminMiddleware, (req, res) => {
const { role } = req.body;
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
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' });
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, target.id);
res.json({ success: true });
});
// Reset user password (admin)
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
const db = getDb();
const hash = bcrypt.hashSync(password, 10);
db.prepare("UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE id = ?").run(hash, req.params.id);
res.json({ success: true });
});
// Suspend user (admin)
router.patch('/:id/suspend', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot suspend default admin' });
db.prepare("UPDATE users SET status = 'suspended', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Activate user (admin)
router.patch('/:id/activate', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
db.prepare("UPDATE users SET status = 'active', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
// Delete user (admin)
router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb();
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin) return res.status(403).json({ error: 'Cannot delete default admin' });
db.prepare("UPDATE users SET status = 'deleted', updated_at = datetime('now') WHERE id = ?").run(target.id);
res.json({ success: true });
});
// Update own profile
router.patch('/me/profile', authMiddleware, (req, res) => {
const { displayName, aboutMe, hideAdminTag } = req.body;
const db = getDb();
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);
res.json({ user });
});
// Upload avatar
router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const db = getDb();
db.prepare("UPDATE users SET avatar = ?, updated_at = datetime('now') WHERE id = ?").run(avatarUrl, req.user.id);
res.json({ avatarUrl });
});
module.exports = router;