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,
|
totp_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
-- Backup codes for 2FA (hashed)
|
-- Backup codes for 2FA (hashed)
|
||||||
totp_backup_codes TEXT[] DEFAULT '{}',
|
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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
last_seen_at TIMESTAMPTZ
|
last_seen_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import authRouter from './routes/auth.js';
|
|||||||
import messagesRouter from './routes/messages.js';
|
import messagesRouter from './routes/messages.js';
|
||||||
import filesRouter from './routes/files.js';
|
import filesRouter from './routes/files.js';
|
||||||
import usersRouter from './routes/users.js';
|
import usersRouter from './routes/users.js';
|
||||||
|
import adminRouter from './routes/admin.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ app.use('/api/auth', authLimiter, authRouter);
|
|||||||
app.use('/api', messagesRouter);
|
app.use('/api', messagesRouter);
|
||||||
app.use('/api', filesRouter);
|
app.use('/api', filesRouter);
|
||||||
app.use('/api', usersRouter);
|
app.use('/api', usersRouter);
|
||||||
|
app.use('/api', adminRouter);
|
||||||
|
|
||||||
// WebSocket: real-time messaging relay
|
// WebSocket: real-time messaging relay
|
||||||
// Server never sees plaintext — it only routes encrypted payloads
|
// 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 { email, password, totpCode } = parsed.data;
|
||||||
|
|
||||||
const result = await db.query(
|
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`,
|
FROM users WHERE email = $1`,
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
@@ -109,6 +109,7 @@ router.post('/login', async (req, res) => {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
kdfSalt: user.kdf_salt,
|
kdfSalt: user.kdf_salt,
|
||||||
encryptedMasterKey: user.encrypted_master_key,
|
encryptedMasterKey: user.encrypted_master_key,
|
||||||
|
isAdmin: user.is_admin,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="100" fill="#1e1b4b"/>
|
||||||
|
<path d="M256 120 C180 120 120 180 120 256 C120 332 180 392 256 392 C332 392 392 332 392 256 C392 180 332 120 256 120Z" fill="none" stroke="#6366f1" stroke-width="24"/>
|
||||||
|
<path d="M200 200 L256 160 L312 200 L312 280 L256 320 L200 280Z" fill="#6366f1" opacity="0.9"/>
|
||||||
|
<circle cx="256" cy="240" r="30" fill="#e0e7ff"/>
|
||||||
|
<path d="M180 320 Q256 360 332 320" fill="none" stroke="#a5b4fc" stroke-width="16" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
+197
@@ -0,0 +1,197 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { app, BrowserWindow, Tray, Menu, ipcMain, Notification, nativeImage, shell } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// electron-store v10 uses ESM — load dynamically
|
||||||
|
let Store;
|
||||||
|
let store;
|
||||||
|
|
||||||
|
async function loadStore() {
|
||||||
|
const mod = await import('electron-store');
|
||||||
|
Store = mod.default;
|
||||||
|
store = new Store({
|
||||||
|
defaults: { serverUrl: '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
let tray = null;
|
||||||
|
let setupWindow = null;
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getIconPath() {
|
||||||
|
if (process.platform === 'darwin') return path.join(__dirname, 'assets', 'icon.icns');
|
||||||
|
if (process.platform === 'win32') return path.join(__dirname, 'assets', 'icon.ico');
|
||||||
|
return path.join(__dirname, 'assets', 'icon.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
const trayImg = nativeImage.createFromPath(
|
||||||
|
path.join(__dirname, 'assets', 'tray.png')
|
||||||
|
);
|
||||||
|
tray = new Tray(trayImg.isEmpty() ? nativeImage.createEmpty() : trayImg);
|
||||||
|
tray.setToolTip('CloudLink');
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate([
|
||||||
|
{ label: 'Open CloudLink', click: () => { mainWindow?.show(); mainWindow?.focus(); } },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Change server URL…',
|
||||||
|
click: () => openSetup(true),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Quit', click: () => app.quit() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
tray.setContextMenu(menu);
|
||||||
|
tray.on('click', () => { mainWindow?.show(); mainWindow?.focus(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSetup(fromTray = false) {
|
||||||
|
if (setupWindow) { setupWindow.focus(); return; }
|
||||||
|
|
||||||
|
setupWindow = new BrowserWindow({
|
||||||
|
width: 520,
|
||||||
|
height: 340,
|
||||||
|
resizable: false,
|
||||||
|
title: 'CloudLink — Setup',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = store.get('serverUrl');
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; height: 100vh; gap: 24px; padding: 32px; }
|
||||||
|
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||||
|
p { font-size: 0.875rem; color: #94a3b8; text-align: center; }
|
||||||
|
input { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #334155;
|
||||||
|
background: #1e293b; color: #e2e8f0; font-size: 0.9rem; outline: none; }
|
||||||
|
input:focus { border-color: #6366f1; }
|
||||||
|
button { padding: 10px 24px; background: #6366f1; color: white; border: none;
|
||||||
|
border-radius: 8px; font-size: 0.9rem; cursor: pointer; }
|
||||||
|
button:hover { background: #4f46e5; }
|
||||||
|
.row { display: flex; gap: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🔗 CloudLink</h1>
|
||||||
|
<p>Enter the URL of your CloudLink server.<br>
|
||||||
|
For local development use <code style="color:#818cf8">http://localhost:5173</code></p>
|
||||||
|
<input id="url" type="url" placeholder="https://cloudlink.yourserver.com"
|
||||||
|
value="${current}" />
|
||||||
|
<div class="row">
|
||||||
|
<button onclick="save()">Connect</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function save() {
|
||||||
|
const v = document.getElementById('url').value.trim().replace(/\\/$/, '');
|
||||||
|
if (!v) return;
|
||||||
|
window.cloudlink.setServerUrl(v);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
setupWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
|
||||||
|
setupWindow.on('closed', () => { setupWindow = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMainWindow(serverUrl) {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 600,
|
||||||
|
title: 'CloudLink',
|
||||||
|
icon: getIconPath(),
|
||||||
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// Allow the web app to use the Web Crypto API and WebSockets
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadURL(serverUrl);
|
||||||
|
|
||||||
|
// Open external links in the default browser
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide to tray instead of quitting on close (macOS/Windows convention)
|
||||||
|
mainWindow.on('close', (e) => {
|
||||||
|
if (process.platform !== 'darwin' && !app.isQuitting) {
|
||||||
|
e.preventDefault();
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => { mainWindow = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IPC ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ipcMain.handle('get-server-url', () => store.get('serverUrl'));
|
||||||
|
|
||||||
|
ipcMain.handle('set-server-url', (_e, url) => {
|
||||||
|
store.set('serverUrl', url);
|
||||||
|
setupWindow?.close();
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.loadURL(url);
|
||||||
|
} else {
|
||||||
|
createMainWindow(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('show-notification', (_e, { title, body }) => {
|
||||||
|
if (Notification.isSupported()) {
|
||||||
|
new Notification({ title, body }).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── App lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await loadStore();
|
||||||
|
createTray();
|
||||||
|
|
||||||
|
const serverUrl = store.get('serverUrl');
|
||||||
|
if (!serverUrl) {
|
||||||
|
openSetup();
|
||||||
|
} else {
|
||||||
|
createMainWindow(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (!mainWindow && store.get('serverUrl')) {
|
||||||
|
createMainWindow(store.get('serverUrl'));
|
||||||
|
} else {
|
||||||
|
mainWindow?.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => { app.isQuitting = true; });
|
||||||
|
|
||||||
|
// Keep running when all windows are closed (show via tray)
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
// don't quit — user can reopen from tray
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+5541
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudlink-desktop",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CloudLink — encrypted messaging and cloud drive",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"build": "electron-builder --mac --win --linux",
|
||||||
|
"build:mac": "electron-builder --mac",
|
||||||
|
"build:win": "electron-builder --win",
|
||||||
|
"build:linux": "electron-builder --linux"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "us.necloud.cloudlink",
|
||||||
|
"productName": "CloudLink",
|
||||||
|
"copyright": "Copyright © 2026",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"assets/**"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.productivity",
|
||||||
|
"icon": "assets/icon.icns",
|
||||||
|
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"icon": "assets/icon.ico",
|
||||||
|
"target": [{ "target": "nsis", "arch": ["x64"] }]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "assets/icon.png",
|
||||||
|
"category": "Network",
|
||||||
|
"target": [{ "target": "AppImage", "arch": ["x64"] }]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"electron-builder": "^25.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-store": "^10.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('cloudlink', {
|
||||||
|
getServerUrl: () => ipcRenderer.invoke('get-server-url'),
|
||||||
|
setServerUrl: (url) => ipcRenderer.invoke('set-server-url', url),
|
||||||
|
showNotification: (title, body) =>
|
||||||
|
ipcRenderer.invoke('show-notification', { title, body }),
|
||||||
|
// Platform detection for the web app
|
||||||
|
platform: process.platform,
|
||||||
|
isDesktop: true,
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import Layout from './components/Layout';
|
|||||||
import MessagesPage from './pages/MessagesPage';
|
import MessagesPage from './pages/MessagesPage';
|
||||||
import DrivePage from './pages/DrivePage';
|
import DrivePage from './pages/DrivePage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import AdminPage from './pages/AdminPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const { accessToken, masterKey } = useAuthStore();
|
const { accessToken, masterKey } = useAuthStore();
|
||||||
@@ -32,6 +33,7 @@ export default function App() {
|
|||||||
<Route path="messages/:id" element={<MessagesPage />} />
|
<Route path="messages/:id" element={<MessagesPage />} />
|
||||||
<Route path="drive" element={<DrivePage />} />
|
<Route path="drive" element={<DrivePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="admin" element={<AdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Outlet, NavLink } from 'react-router-dom';
|
import { Outlet, NavLink } from 'react-router-dom';
|
||||||
import { MessageSquare, HardDrive, Settings, LogOut, Shield } from 'lucide-react';
|
import { MessageSquare, HardDrive, Settings, LogOut, Shield, ShieldCheck } from 'lucide-react';
|
||||||
import { useAuthStore } from '../lib/store';
|
import { useAuthStore } from '../lib/store';
|
||||||
import { socket } from '../lib/ws';
|
import { socket } from '../lib/ws';
|
||||||
|
|
||||||
const nav = [
|
const baseNav = [
|
||||||
{ to: '/messages', icon: MessageSquare, label: 'Messages' },
|
{ to: '/messages', icon: MessageSquare, label: 'Messages' },
|
||||||
{ to: '/drive', icon: HardDrive, label: 'Drive' },
|
{ to: '/drive', icon: HardDrive, label: 'Drive' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { logout, username } = useAuthStore();
|
const { logout, username, isAdmin } = useAuthStore();
|
||||||
|
const nav = isAdmin ? [...baseNav, { to: '/admin', icon: ShieldCheck, label: 'Admin' }] : baseNav;
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ interface AuthState {
|
|||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
// Crypto keys — in-memory only, never persisted
|
// Crypto keys — in-memory only, never persisted
|
||||||
masterKey: CryptoKey | null;
|
masterKey: CryptoKey | null;
|
||||||
privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey)
|
privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey)
|
||||||
publicKey: string | null; // X25519 public key (base64)
|
publicKey: string | null; // X25519 public key (base64)
|
||||||
setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void;
|
setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean }) => void;
|
||||||
setTokens: (access: string, refresh: string) => void;
|
setTokens: (access: string, refresh: string) => void;
|
||||||
setMasterKey: (key: CryptoKey) => void;
|
setMasterKey: (key: CryptoKey) => void;
|
||||||
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
|
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
|
||||||
@@ -25,11 +26,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
|
isAdmin: false,
|
||||||
masterKey: null,
|
masterKey: null,
|
||||||
privateKey: null as CryptoKey | null,
|
privateKey: null as CryptoKey | null,
|
||||||
publicKey: null,
|
publicKey: null,
|
||||||
setAuth: ({ accessToken, refreshToken, userId }) =>
|
setAuth: ({ accessToken, refreshToken, userId, isAdmin }) =>
|
||||||
set({ accessToken, refreshToken, userId }),
|
set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false }),
|
||||||
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
||||||
setMasterKey: (masterKey) => set({ masterKey }),
|
setMasterKey: (masterKey) => set({ masterKey }),
|
||||||
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
|
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
|
||||||
@@ -52,6 +54,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
refreshToken: s.refreshToken,
|
refreshToken: s.refreshToken,
|
||||||
userId: s.userId,
|
userId: s.userId,
|
||||||
username: s.username,
|
username: s.username,
|
||||||
|
isAdmin: s.isAdmin,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Shield, HardDrive, Users, Settings2 } from 'lucide-react';
|
||||||
|
import api from '../lib/api';
|
||||||
|
import { useAuthStore } from '../lib/store';
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
storage_quota_mb: number;
|
||||||
|
storage_used_bytes: number;
|
||||||
|
file_count: number;
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
total_users: number;
|
||||||
|
total_storage_bytes: number;
|
||||||
|
total_files: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { userId } = useAuthStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState<Record<string, { quota: number; admin: boolean }>>({});
|
||||||
|
|
||||||
|
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: () => api.get('/admin/users').then((r) => r.data),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: stats } = useQuery<Stats>({
|
||||||
|
queryKey: ['admin-stats'],
|
||||||
|
queryFn: () => api.get('/admin/stats').then((r) => r.data),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: object }) =>
|
||||||
|
api.patch(`/admin/users/${id}`, body).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function startEdit(u: AdminUser) {
|
||||||
|
setEditing((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[u.id]: { quota: u.storage_quota_mb, admin: u.is_admin },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit(id: string) {
|
||||||
|
const e = editing[id];
|
||||||
|
if (!e) return;
|
||||||
|
patch.mutate({ id, body: { storage_quota_mb: e.quota, is_admin: e.admin } });
|
||||||
|
setEditing((prev) => { const n = { ...prev }; delete n[id]; return n; });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-slate-500">
|
||||||
|
Loading admin portal…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 max-w-5xl mx-auto w-full">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Shield className="w-6 h-6 text-accent" />
|
||||||
|
<h1 className="text-xl font-bold">Admin Portal</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
{[
|
||||||
|
{ icon: Users, label: 'Total users', value: stats.total_users },
|
||||||
|
{ icon: HardDrive, label: 'Total storage', value: fmt(Number(stats.total_storage_bytes)) },
|
||||||
|
{ icon: Settings2, label: 'Total files', value: stats.total_files },
|
||||||
|
].map(({ icon: Icon, label, value }) => (
|
||||||
|
<div key={label} className="bg-surface-1 border border-slate-700 rounded-xl p-4 flex items-center gap-3">
|
||||||
|
<Icon className="w-5 h-5 text-accent shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">{label}</p>
|
||||||
|
<p className="text-lg font-bold">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users table */}
|
||||||
|
<div className="bg-surface-1 border border-slate-700 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-slate-700 bg-surface-2">
|
||||||
|
<tr>
|
||||||
|
{['User', 'Storage used / quota', 'Files', 'Admin', 'Actions'].map((h) => (
|
||||||
|
<th key={h} className="text-left px-4 py-3 text-xs text-slate-400 font-medium">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const e = editing[u.id];
|
||||||
|
const usedMb = Number(u.storage_used_bytes) / 1024 / 1024;
|
||||||
|
const pct = Math.min(100, (usedMb / (e?.quota ?? u.storage_quota_mb)) * 100);
|
||||||
|
const isSelf = u.id === userId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-slate-700/50 hover:bg-surface-2 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium">{u.username}{isSelf && <span className="ml-1 text-xs text-accent">(you)</span>}</p>
|
||||||
|
<p className="text-xs text-slate-500">{u.email}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-56">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs">{fmt(Number(u.storage_used_bytes))}</span>
|
||||||
|
<span className="text-xs text-slate-500">of</span>
|
||||||
|
{e ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="input w-20 text-xs py-0.5 px-2"
|
||||||
|
value={e.quota}
|
||||||
|
onChange={(ev) => setEditing((prev) => ({ ...prev, [u.id]: { ...prev[u.id], quota: Number(ev.target.value) } }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{u.storage_quota_mb} MB</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-accent'}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{u.file_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{e ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={e.admin}
|
||||||
|
onChange={(ev) => setEditing((prev) => ({ ...prev, [u.id]: { ...prev[u.id], admin: ev.target.checked } }))}
|
||||||
|
className="w-4 h-4 accent-indigo-500"
|
||||||
|
disabled={isSelf}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${u.is_admin ? 'bg-accent/20 text-accent' : 'bg-slate-700 text-slate-400'}`}>
|
||||||
|
{u.is_admin ? 'Admin' : 'User'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{e ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => saveEdit(u.id)} className="btn-primary text-xs py-1 px-3">Save</button>
|
||||||
|
<button onClick={() => setEditing((prev) => { const n = { ...prev }; delete n[u.id]; return n; })} className="text-xs text-slate-400 hover:text-white">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => startEdit(u)} className="text-xs text-accent hover:underline">Edit</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,14 +34,14 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey } = res.data;
|
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey, isAdmin } = res.data;
|
||||||
|
|
||||||
// Re-derive master key from password — never leaves the browser
|
// Re-derive master key from password — never leaves the browser
|
||||||
const derived = await deriveKeyFromPassword(password, kdfSalt);
|
const derived = await deriveKeyFromPassword(password, kdfSalt);
|
||||||
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
|
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
|
||||||
|
|
||||||
// Set tokens first so the next API call has a Bearer token
|
// Set tokens first so the next API call has a Bearer token
|
||||||
setAuth({ accessToken, refreshToken, userId });
|
setAuth({ accessToken, refreshToken, userId, isAdmin });
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
setUsername(username);
|
setUsername(username);
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ export default function MessagesPage() {
|
|||||||
load();
|
load();
|
||||||
}, [activeId, conversations.length]);
|
}, [activeId, conversations.length]);
|
||||||
|
|
||||||
// WebSocket: decrypt incoming messages in real time
|
// WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return socket.on(async (raw) => {
|
return socket.on(async (raw) => {
|
||||||
const msg = raw as any;
|
const msg = raw as any;
|
||||||
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
|
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
|
||||||
|
if (msg.senderId === userId) return; // Own message already added optimistically
|
||||||
|
|
||||||
const convKey = getConvKey(activeId!);
|
const convKey = getConvKey(activeId!);
|
||||||
if (!convKey) return;
|
if (!convKey) return;
|
||||||
@@ -95,10 +96,10 @@ export default function MessagesPage() {
|
|||||||
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sent_at ?? new Date().toISOString() },
|
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sentAt ?? new Date().toISOString() },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}, [activeId]);
|
}, [activeId, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -109,9 +110,22 @@ export default function MessagesPage() {
|
|||||||
const convKey = getConvKey(activeId);
|
const convKey = getConvKey(activeId);
|
||||||
if (!convKey) return;
|
if (!convKey) return;
|
||||||
|
|
||||||
const ciphertext = await encryptMessage(text.trim(), convKey);
|
const plaintext = text.trim();
|
||||||
|
setText(''); // Clear immediately for responsiveness
|
||||||
|
|
||||||
|
const ciphertext = await encryptMessage(plaintext, convKey);
|
||||||
|
|
||||||
|
// Optimistic update — show in UI before server confirms
|
||||||
|
setMessages((prev) => [...prev, {
|
||||||
|
id: `pending-${Date.now()}`,
|
||||||
|
sender_id: userId!,
|
||||||
|
sender_username: useAuthStore.getState().username || userId!,
|
||||||
|
ciphertext,
|
||||||
|
plaintext,
|
||||||
|
sent_at: new Date().toISOString(),
|
||||||
|
}]);
|
||||||
|
|
||||||
socket.send({ type: 'message', conversationId: activeId, ciphertext });
|
socket.send({ type: 'message', conversationId: activeId, ciphertext });
|
||||||
setText('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startConversation(recipient: UserResult) {
|
async function startConversation(recipient: UserResult) {
|
||||||
|
|||||||
Reference in new issue
Block a user