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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user