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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 10:31:41 -04:00
1 parent 46f0a4b71e
commit 9b57dd9b94
15 files changed
+6104 -14

No files matched your search

+2
View File
@@ -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
);
+2
View File
@@ -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
+70
View File
@@ -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;
+2 -1
View File
@@ -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,
});
});