feat: real-time messaging, Electron desktop app, admin drive portal
Messaging: - Optimistic UI: message appears instantly in sender's view on send - Skip WS relay-back of own messages to avoid duplicates - Fix sent_at field name mismatch (sentAt → sent_at fallback) Admin portal: - /admin route: list all users, storage used vs quota, file count - Inline-edit per-user storage quota (MB) and admin flag - Stats header: total users, total storage, total files - is_admin + storage_quota_mb columns added to users table - Login response now includes isAdmin; sidebar shows Admin link for admins - requireAdmin middleware on all /admin/* routes Electron desktop app (desktop/): - BrowserWindow with native title bar, system tray, hide-to-tray on close - First-run setup dialog to configure self-hosted server URL - Persists server URL via electron-store - Tray menu: Open, Change server URL, Quit - IPC: showNotification for future native push notifications - electron-builder config for macOS (DMG), Windows (NSIS), Linux (AppImage) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
46f0a4b71e
commit
9b57dd9b94
15 files changed
+6104
-14
No files matched your search
@@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
totp_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
-- Backup codes for 2FA (hashed)
|
||||
totp_backup_codes TEXT[] DEFAULT '{}',
|
||||
is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import authRouter from './routes/auth.js';
|
||||
import messagesRouter from './routes/messages.js';
|
||||
import filesRouter from './routes/files.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -29,6 +30,7 @@ app.use('/api/auth', authLimiter, authRouter);
|
||||
app.use('/api', messagesRouter);
|
||||
app.use('/api', filesRouter);
|
||||
app.use('/api', usersRouter);
|
||||
app.use('/api', adminRouter);
|
||||
|
||||
// WebSocket: real-time messaging relay
|
||||
// Server never sees plaintext — it only routes encrypted payloads
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { db } from '../lib/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
const userId = (req as any).userId;
|
||||
const result = await db.query('SELECT is_admin FROM users WHERE id = $1', [userId]);
|
||||
if (!result.rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /admin/users — list all users with storage usage and quota
|
||||
router.get('/admin/users', requireAuth, requireAdmin, async (req, res) => {
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
u.id, u.username, u.email, u.is_admin, u.storage_quota_mb,
|
||||
u.created_at, u.last_seen_at,
|
||||
COALESCE(SUM(f.size_bytes) FILTER (WHERE f.deleted_at IS NULL), 0)::BIGINT AS storage_used_bytes,
|
||||
COUNT(f.id) FILTER (WHERE f.deleted_at IS NULL) AS file_count
|
||||
FROM users u
|
||||
LEFT JOIN files f ON f.owner_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at ASC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
});
|
||||
|
||||
// PATCH /admin/users/:id — update quota or admin status
|
||||
router.patch('/admin/users/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { storage_quota_mb, is_admin } = req.body;
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (storage_quota_mb !== undefined) {
|
||||
values.push(Number(storage_quota_mb));
|
||||
updates.push(`storage_quota_mb = $${values.length}`);
|
||||
}
|
||||
if (is_admin !== undefined) {
|
||||
values.push(Boolean(is_admin));
|
||||
updates.push(`is_admin = $${values.length}`);
|
||||
}
|
||||
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
|
||||
|
||||
values.push(id);
|
||||
const result = await db.query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${values.length}
|
||||
RETURNING id, username, is_admin, storage_quota_mb`,
|
||||
values
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(result.rows[0]);
|
||||
});
|
||||
|
||||
// GET /admin/stats — overall storage and user counts
|
||||
router.get('/admin/stats', requireAuth, requireAdmin, async (req, res) => {
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
COUNT(u.id) AS total_users,
|
||||
COALESCE(SUM(f.size_bytes) FILTER (WHERE f.deleted_at IS NULL), 0)::BIGINT AS total_storage_bytes,
|
||||
COUNT(f.id) FILTER (WHERE f.deleted_at IS NULL) AS total_files
|
||||
FROM users u
|
||||
LEFT JOIN files f ON f.owner_id = u.id
|
||||
`);
|
||||
res.json(result.rows[0]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -83,7 +83,7 @@ router.post('/login', async (req, res) => {
|
||||
const { email, password, totpCode } = parsed.data;
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT id, username, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key
|
||||
`SELECT id, username, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key, is_admin
|
||||
FROM users WHERE email = $1`,
|
||||
[email]
|
||||
);
|
||||
@@ -109,6 +109,7 @@ router.post('/login', async (req, res) => {
|
||||
username: user.username,
|
||||
kdfSalt: user.kdf_salt,
|
||||
encryptedMasterKey: user.encrypted_master_key,
|
||||
isAdmin: user.is_admin,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user