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
@@ -6,6 +6,7 @@ import Layout from './components/Layout';
import MessagesPage from './pages/MessagesPage';
import DrivePage from './pages/DrivePage';
import SettingsPage from './pages/SettingsPage';
import AdminPage from './pages/AdminPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { accessToken, masterKey } = useAuthStore();
@@ -32,6 +33,7 @@ export default function App() {
<Route path="messages/:id" element={<MessagesPage />} />
<Route path="drive" element={<DrivePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />
</Route>
</Routes>
);
+4 -3
View File
@@ -1,16 +1,17 @@
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 { socket } from '../lib/ws';
const nav = [
const baseNav = [
{ to: '/messages', icon: MessageSquare, label: 'Messages' },
{ to: '/drive', icon: HardDrive, label: 'Drive' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
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() {
socket.disconnect();
+6 -3
View File
@@ -6,11 +6,12 @@ interface AuthState {
refreshToken: string | null;
userId: string | null;
username: string | null;
isAdmin: boolean;
// Crypto keys — in-memory only, never persisted
masterKey: CryptoKey | null;
privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey)
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;
setMasterKey: (key: CryptoKey) => void;
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
@@ -25,11 +26,12 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null,
userId: null,
username: null,
isAdmin: false,
masterKey: null,
privateKey: null as CryptoKey | null,
publicKey: null,
setAuth: ({ accessToken, refreshToken, userId }) =>
set({ accessToken, refreshToken, userId }),
setAuth: ({ accessToken, refreshToken, userId, isAdmin }) =>
set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false }),
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
setMasterKey: (masterKey) => set({ masterKey }),
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
@@ -52,6 +54,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: s.refreshToken,
userId: s.userId,
username: s.username,
isAdmin: s.isAdmin,
}),
}
)
+186
View File
@@ -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>
);
}
+2 -2
View File
@@ -34,14 +34,14 @@ export default function LoginPage() {
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
const derived = await deriveKeyFromPassword(password, kdfSalt);
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
// Set tokens first so the next API call has a Bearer token
setAuth({ accessToken, refreshToken, userId });
setAuth({ accessToken, refreshToken, userId, isAdmin });
setMasterKey(masterKey);
setUsername(username);
+19 -5
View File
@@ -83,11 +83,12 @@ export default function MessagesPage() {
load();
}, [activeId, conversations.length]);
// WebSocket: decrypt incoming messages in real time
// WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically)
useEffect(() => {
return socket.on(async (raw) => {
const msg = raw as any;
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
if (msg.senderId === userId) return; // Own message already added optimistically
const convKey = getConvKey(activeId!);
if (!convKey) return;
@@ -95,10 +96,10 @@ export default function MessagesPage() {
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
setMessages((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(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -109,9 +110,22 @@ export default function MessagesPage() {
const convKey = getConvKey(activeId);
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 });
setText('');
}
async function startConversation(recipient: UserResult) {