Initial CloudLink scaffold: E2E encrypted PWA with messaging and cloud drive
- Zero-knowledge architecture: all encryption/decryption client-side via Web Crypto API - AES-GCM 256 for messages, files, and key wrapping; PBKDF2 for password-based key derivation - Node.js/TypeScript backend: JWT auth with refresh rotation, argon2id passwords, TOTP 2FA - PostgreSQL schema: encrypted blobs only, server never stores plaintext - Real-time messaging via WebSocket relay (server routes ciphertext, cannot read it) - Encrypted file upload/download with per-file keys wrapped by user master key - React PWA frontend: Vite + Tailwind + service worker for offline support - Docker Compose: postgres + backend + frontend + nginx with TLS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
937bf8ad2d
36 files changed
+2325
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './lib/store';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import Layout from './components/Layout';
|
||||
import MessagesPage from './pages/MessagesPage';
|
||||
import DrivePage from './pages/DrivePage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { accessToken, masterKey } = useAuthStore();
|
||||
if (!accessToken) return <Navigate to="/login" replace />;
|
||||
if (!masterKey) return <Navigate to="/login" state={{ reauth: true }} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/messages" replace />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
<Route path="messages/:id" element={<MessagesPage />} />
|
||||
<Route path="drive" element={<DrivePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Outlet, NavLink } from 'react-router-dom';
|
||||
import { MessageSquare, HardDrive, Settings, LogOut, Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '../lib/store';
|
||||
import { socket } from '../lib/ws';
|
||||
|
||||
const nav = [
|
||||
{ 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();
|
||||
|
||||
function handleLogout() {
|
||||
socket.disconnect();
|
||||
logout();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-16 md:w-56 flex flex-col bg-surface-1 border-r border-slate-700 shrink-0">
|
||||
<div className="flex items-center gap-2 px-4 py-5 border-b border-slate-700">
|
||||
<Shield className="text-accent w-6 h-6 shrink-0" />
|
||||
<span className="hidden md:block font-bold text-lg tracking-tight">CloudLink</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{nav.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-surface-2'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
<span className="hidden md:block">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-2 px-1">
|
||||
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{username?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span className="hidden md:block text-sm text-slate-300 truncate">{username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0" />
|
||||
<span className="hidden md:block">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// All encryption/decryption happens here — the server never sees plaintext.
|
||||
// Uses Web Crypto API (AES-GCM 256 + PBKDF2 for key derivation).
|
||||
|
||||
const ENC = new TextEncoder();
|
||||
const DEC = new TextDecoder();
|
||||
|
||||
// ── Key derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function generateKdfSalt(): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
return bufToB64(salt);
|
||||
}
|
||||
|
||||
export async function deriveKeyFromPassword(
|
||||
password: string,
|
||||
saltB64: string
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await crypto.subtle.importKey('raw', ENC.encode(password), 'PBKDF2', false, [
|
||||
'deriveKey',
|
||||
]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: b64ToBuf(saltB64), iterations: 600_000, hash: 'SHA-256' },
|
||||
raw,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// ── Master key ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function generateMasterKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
'wrapKey',
|
||||
'unwrapKey',
|
||||
]);
|
||||
}
|
||||
|
||||
export async function exportMasterKey(key: CryptoKey): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
return bufToB64(raw);
|
||||
}
|
||||
|
||||
/** Encrypt master key with the password-derived key — stored on server */
|
||||
export async function encryptMasterKey(
|
||||
masterKey: CryptoKey,
|
||||
derivedKey: CryptoKey
|
||||
): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey('raw', masterKey);
|
||||
return aesEncryptRaw(new Uint8Array(raw), derivedKey);
|
||||
}
|
||||
|
||||
export async function decryptMasterKey(
|
||||
encryptedB64: string,
|
||||
derivedKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await aesDecryptRaw(encryptedB64, derivedKey);
|
||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
'wrapKey',
|
||||
'unwrapKey',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Message encryption ──────────────────────────────────────────────────────
|
||||
|
||||
/** Encrypt a message string with the conversation's shared key */
|
||||
export async function encryptMessage(plaintext: string, key: CryptoKey): Promise<string> {
|
||||
return aesEncryptRaw(ENC.encode(plaintext), key);
|
||||
}
|
||||
|
||||
export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise<string> {
|
||||
const raw = await aesDecryptRaw(ciphertextB64, key);
|
||||
return DEC.decode(raw);
|
||||
}
|
||||
|
||||
// ── File encryption ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function generateFileKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** Encrypt a file blob client-side before upload */
|
||||
export async function encryptFile(file: File, key: CryptoKey): Promise<Blob> {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
|
||||
const combined = new Uint8Array(nonce.length + ct.byteLength);
|
||||
combined.set(nonce);
|
||||
combined.set(new Uint8Array(ct), nonce.length);
|
||||
return new Blob([combined], { type: 'application/octet-stream' });
|
||||
}
|
||||
|
||||
export async function decryptFile(encryptedBlob: Blob, key: CryptoKey): Promise<Uint8Array> {
|
||||
const data = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||
const nonce = data.slice(0, 12);
|
||||
const ct = data.slice(12);
|
||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
|
||||
return new Uint8Array(pt);
|
||||
}
|
||||
|
||||
/** Encrypt a file key with the master key — stored alongside file metadata */
|
||||
export async function encryptFileKey(fileKey: CryptoKey, masterKey: CryptoKey): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey('raw', fileKey);
|
||||
return aesEncryptRaw(new Uint8Array(raw), masterKey);
|
||||
}
|
||||
|
||||
export async function decryptFileKey(
|
||||
encryptedB64: string,
|
||||
masterKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await aesDecryptRaw(encryptedB64, masterKey);
|
||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Conversation key ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a shared conversation key */
|
||||
export async function generateConversationKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** Encrypt conversation key with a user's master key for storage */
|
||||
export async function encryptConversationKey(
|
||||
convKey: CryptoKey,
|
||||
masterKey: CryptoKey
|
||||
): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey('raw', convKey);
|
||||
return aesEncryptRaw(new Uint8Array(raw), masterKey);
|
||||
}
|
||||
|
||||
export async function decryptConversationKey(
|
||||
encryptedB64: string,
|
||||
masterKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
const raw = await aesDecryptRaw(encryptedB64, masterKey);
|
||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise<string> {
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
|
||||
const combined = new Uint8Array(nonce.length + ct.byteLength);
|
||||
combined.set(nonce);
|
||||
combined.set(new Uint8Array(ct), nonce.length);
|
||||
return bufToB64(combined);
|
||||
}
|
||||
|
||||
async function aesDecryptRaw(b64: string, key: CryptoKey): Promise<Uint8Array> {
|
||||
const combined = b64ToBuf(b64);
|
||||
const nonce = combined.slice(0, 12);
|
||||
const ct = combined.slice(12);
|
||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
|
||||
return new Uint8Array(pt);
|
||||
}
|
||||
|
||||
export function bufToB64(buf: ArrayBuffer | Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||
}
|
||||
|
||||
export function b64ToBuf(b64: string): Uint8Array {
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-surface text-slate-100 font-sans antialiased;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-slate-600 rounded-full; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.input {
|
||||
@apply bg-surface-2 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-colors;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-accent hover:bg-accent-hover text-white font-medium rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from './store';
|
||||
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api' });
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
async (err) => {
|
||||
if (err.response?.status !== 401) throw err;
|
||||
|
||||
const store = useAuthStore.getState();
|
||||
if (!store.refreshToken) { store.logout(); throw err; }
|
||||
|
||||
if (!refreshing) {
|
||||
refreshing = api
|
||||
.post('/auth/refresh', { refreshToken: store.refreshToken })
|
||||
.then((r) => {
|
||||
store.setTokens(r.data.accessToken, r.data.refreshToken);
|
||||
})
|
||||
.catch(() => store.logout())
|
||||
.finally(() => { refreshing = null; });
|
||||
}
|
||||
|
||||
await refreshing;
|
||||
|
||||
const newToken = useAuthStore.getState().accessToken;
|
||||
if (!newToken) throw err;
|
||||
err.config.headers.Authorization = `Bearer ${newToken}`;
|
||||
return api.request(err.config);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
// Base64 master key — loaded into memory from password derivation, NOT persisted
|
||||
masterKey: CryptoKey | null;
|
||||
setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void;
|
||||
setTokens: (access: string, refresh: string) => void;
|
||||
setMasterKey: (key: CryptoKey) => void;
|
||||
setUsername: (u: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
userId: null,
|
||||
username: null,
|
||||
masterKey: null,
|
||||
setAuth: ({ accessToken, refreshToken, userId }) =>
|
||||
set({ accessToken, refreshToken, userId }),
|
||||
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
||||
setMasterKey: (masterKey) => set({ masterKey }),
|
||||
setUsername: (username) => set({ username }),
|
||||
logout: () =>
|
||||
set({ accessToken: null, refreshToken: null, userId: null, masterKey: null }),
|
||||
}),
|
||||
{
|
||||
name: 'cloudlink-auth',
|
||||
// Never persist the master key — must be re-derived on login
|
||||
partialize: (s) => ({
|
||||
accessToken: s.accessToken,
|
||||
refreshToken: s.refreshToken,
|
||||
userId: s.userId,
|
||||
username: s.username,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
interface ConvKey {
|
||||
conversationId: string;
|
||||
key: CryptoKey;
|
||||
}
|
||||
|
||||
interface CryptoStore {
|
||||
convKeys: Map<string, CryptoKey>;
|
||||
setConvKey: (conversationId: string, key: CryptoKey) => void;
|
||||
getConvKey: (conversationId: string) => CryptoKey | undefined;
|
||||
}
|
||||
|
||||
export const useCryptoStore = create<CryptoStore>((set, get) => ({
|
||||
convKeys: new Map(),
|
||||
setConvKey: (conversationId, key) =>
|
||||
set((s) => ({ convKeys: new Map(s.convKeys).set(conversationId, key) })),
|
||||
getConvKey: (conversationId) => get().convKeys.get(conversationId),
|
||||
}));
|
||||
@@ -0,0 +1,44 @@
|
||||
type MessageHandler = (msg: unknown) => void;
|
||||
|
||||
class CloudLinkSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: Set<MessageHandler> = new Set();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
connect(token: string) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const url = `${import.meta.env.VITE_WS_URL || '/ws'}?token=${token}`;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
this.handlers.forEach((h) => h(msg));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(token), 3000);
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
send(data: object) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
on(handler: MessageHandler) {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new CloudLinkSocket();
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const qc = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore } from '../lib/store';
|
||||
import {
|
||||
generateFileKey,
|
||||
encryptFile,
|
||||
encryptFileKey,
|
||||
decryptFileKey,
|
||||
decryptFile,
|
||||
encryptMessage,
|
||||
decryptMessage,
|
||||
} from '../crypto';
|
||||
|
||||
interface DriveFile {
|
||||
id: string;
|
||||
encrypted_name: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
encrypted_file_key: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function DrivePage() {
|
||||
const { masterKey } = useAuthStore();
|
||||
const qc = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [decryptedNames, setDecryptedNames] = useState<Record<string, string>>({});
|
||||
|
||||
const { data: files = [] } = useQuery<DriveFile[]>({
|
||||
queryKey: ['files'],
|
||||
queryFn: () => api.get('/files').then((r) => r.data),
|
||||
onSuccess: async (files) => {
|
||||
if (!masterKey) return;
|
||||
const names: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
files.map(async (f) => {
|
||||
try {
|
||||
const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey);
|
||||
names[f.id] = await decryptMessage(f.encrypted_name, fileKey);
|
||||
} catch {
|
||||
names[f.id] = '[encrypted]';
|
||||
}
|
||||
})
|
||||
);
|
||||
setDecryptedNames(names);
|
||||
},
|
||||
} as any);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !masterKey) return;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const fileKey = await generateFileKey();
|
||||
const encryptedBlob = await encryptFile(file, fileKey);
|
||||
const encryptedFileKey = await encryptFileKey(fileKey, masterKey);
|
||||
const encryptedName = await encryptMessage(file.name, fileKey);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', encryptedBlob, 'blob');
|
||||
form.append('encryptedName', encryptedName);
|
||||
form.append('encryptedFileKey', encryptedFileKey);
|
||||
form.append('mimeType', file.type || 'application/octet-stream');
|
||||
|
||||
await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
qc.invalidateQueries({ queryKey: ['files'] });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(f: DriveFile) {
|
||||
if (!masterKey) return;
|
||||
const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey);
|
||||
const res = await api.get(`/files/${f.id}/download`, { responseType: 'blob' });
|
||||
const decrypted = await decryptFile(res.data, fileKey);
|
||||
const filename = decryptedNames[f.id] ?? 'download';
|
||||
|
||||
const url = URL.createObjectURL(new Blob([decrypted]));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await api.delete(`/files/${id}`);
|
||||
qc.invalidateQueries({ queryKey: ['files'] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="px-6 py-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Encrypted Drive</h1>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
|
||||
<Lock className="w-3 h-3" /> Files are encrypted before upload
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploading ? 'Encrypting…' : 'Upload'}
|
||||
</button>
|
||||
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-3">
|
||||
<File className="w-10 h-10" />
|
||||
<p>No files yet — upload your first encrypted file</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{files.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700"
|
||||
>
|
||||
<File className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{decryptedNames[f.id] ?? '…'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatBytes(f.size_bytes)} ·{' '}
|
||||
{formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(f)}
|
||||
className="p-1.5 text-slate-400 hover:text-white transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(f.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore } from '../lib/store';
|
||||
import { deriveKeyFromPassword, decryptMasterKey } from '../crypto';
|
||||
import { socket } from '../lib/ws';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth, setMasterKey, setUsername } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [require2fa, setRequire2fa] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
...(totpCode ? { totpCode } : {}),
|
||||
});
|
||||
|
||||
if (res.data.require2fa) {
|
||||
setRequire2fa(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, userId, kdfSalt, encryptedMasterKey, username } =
|
||||
res.data;
|
||||
|
||||
// Derive key from password — never leaves the browser
|
||||
const derived = await deriveKeyFromPassword(password, kdfSalt);
|
||||
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
|
||||
|
||||
setAuth({ accessToken, refreshToken, userId });
|
||||
setMasterKey(masterKey);
|
||||
setUsername(username);
|
||||
|
||||
socket.connect(accessToken);
|
||||
navigate('/messages');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Shield className="w-12 h-12 text-accent mb-3" />
|
||||
<h1 className="text-2xl font-bold">CloudLink</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">End-to-end encrypted</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
|
||||
<h2 className="font-semibold text-lg">Sign in</h2>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
{!require2fa ? (
|
||||
<>
|
||||
<Field label="Email" type="email" value={email} onChange={setEmail} />
|
||||
<Field label="Password" type="password" value={password} onChange={setPassword} />
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Authenticator code</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-slate-400 mt-4">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="text-accent hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
type: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">{label}</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Send, Plus, Search } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore, useCryptoStore } from '../lib/store';
|
||||
import { socket } from '../lib/ws';
|
||||
import {
|
||||
encryptMessage,
|
||||
decryptMessage,
|
||||
generateConversationKey,
|
||||
encryptConversationKey,
|
||||
decryptConversationKey,
|
||||
} from '../crypto';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
members: { id: string; username: string }[];
|
||||
encrypted_key: string;
|
||||
last_message_at: string | null;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
sender_id: string;
|
||||
sender_username: string;
|
||||
ciphertext: string;
|
||||
sent_at: string;
|
||||
plaintext?: string;
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { id: activeId } = useParams();
|
||||
const { userId, masterKey } = useAuthStore();
|
||||
const { setConvKey, getConvKey } = useCryptoStore();
|
||||
const qc = useQueryClient();
|
||||
const [text, setText] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newUserSearch, setNewUserSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: conversations = [] } = useQuery<Conversation[]>({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: () => api.get('/conversations').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Load + decrypt messages when conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeId || !masterKey) return;
|
||||
|
||||
async function load() {
|
||||
const conv = conversations.find((c) => c.id === activeId);
|
||||
if (!conv) return;
|
||||
|
||||
let convKey = getConvKey(activeId!);
|
||||
if (!convKey) {
|
||||
convKey = await decryptConversationKey(conv.encrypted_key, masterKey!);
|
||||
setConvKey(activeId!, convKey);
|
||||
}
|
||||
|
||||
const res = await api.get(`/conversations/${activeId}/messages`);
|
||||
const decrypted = await Promise.all(
|
||||
res.data.map(async (m: Message) => ({
|
||||
...m,
|
||||
plaintext: await decryptMessage(m.ciphertext, convKey!).catch(() => '[encrypted]'),
|
||||
}))
|
||||
);
|
||||
setMessages(decrypted);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [activeId, conversations.length]);
|
||||
|
||||
// WebSocket incoming messages
|
||||
useEffect(() => {
|
||||
return socket.on(async (raw) => {
|
||||
const msg = raw as any;
|
||||
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
|
||||
|
||||
const convKey = getConvKey(activeId!);
|
||||
if (!convKey) return;
|
||||
|
||||
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext },
|
||||
]);
|
||||
});
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
async function sendMessage() {
|
||||
if (!text.trim() || !activeId) return;
|
||||
const convKey = getConvKey(activeId);
|
||||
if (!convKey) return;
|
||||
|
||||
const ciphertext = await encryptMessage(text.trim(), convKey);
|
||||
socket.send({ type: 'message', conversationId: activeId, ciphertext });
|
||||
setText('');
|
||||
}
|
||||
|
||||
async function startConversation(recipientId: string, recipientUsername: string) {
|
||||
if (!masterKey) return;
|
||||
|
||||
const convKey = await generateConversationKey();
|
||||
const myEncKey = await encryptConversationKey(convKey, masterKey);
|
||||
const recipientEncKey = await encryptConversationKey(convKey, masterKey); // real impl: encrypt with recipient's pubkey
|
||||
|
||||
const res = await api.post('/conversations', {
|
||||
recipientId,
|
||||
encryptedKeys: {
|
||||
[userId!]: myEncKey,
|
||||
[recipientId]: recipientEncKey,
|
||||
},
|
||||
});
|
||||
|
||||
setConvKey(res.data.conversationId, convKey);
|
||||
qc.invalidateQueries({ queryKey: ['conversations'] });
|
||||
setNewUserSearch('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
|
||||
async function handleSearch(q: string) {
|
||||
setNewUserSearch(q);
|
||||
if (q.length < 2) { setSearchResults([]); return; }
|
||||
const res = await api.get(`/users/search?q=${encodeURIComponent(q)}`);
|
||||
setSearchResults(res.data);
|
||||
}
|
||||
|
||||
const activeConv = conversations.find((c) => c.id === activeId);
|
||||
const otherMember = activeConv?.members.find((m) => m.id !== userId);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Conversation list */}
|
||||
<aside className="w-64 border-r border-slate-700 flex flex-col bg-surface-1 shrink-0">
|
||||
<div className="p-3 border-b border-slate-700">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
className="input w-full pl-8 text-sm"
|
||||
placeholder="Find or start chat…"
|
||||
value={newUserSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden">
|
||||
{searchResults.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => startConversation(u.id, u.username)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
{u.username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.map((conv) => {
|
||||
const other = conv.members.find((m) => m.id !== userId);
|
||||
return (
|
||||
<a
|
||||
key={conv.id}
|
||||
href={`/messages/${conv.id}`}
|
||||
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
|
||||
activeId === conv.id ? 'bg-surface-2' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{other?.username[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{other?.username ?? 'Unknown'}</p>
|
||||
{conv.last_message_at && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatDistanceToNow(new Date(conv.last_message_at), { addSuffix: true })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{activeId ? (
|
||||
<>
|
||||
<header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold">
|
||||
{otherMember?.username[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span className="font-medium">{otherMember?.username ?? 'Conversation'}</span>
|
||||
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block" />
|
||||
E2E encrypted
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{messages.map((m) => {
|
||||
const mine = m.sender_id === userId;
|
||||
return (
|
||||
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
|
||||
mine
|
||||
? 'bg-accent text-white rounded-br-sm'
|
||||
: 'bg-surface-1 text-slate-100 rounded-bl-sm'
|
||||
}`}
|
||||
>
|
||||
<p>{m.plaintext}</p>
|
||||
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||
{formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-slate-700 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Message…"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
/>
|
||||
<button onClick={sendMessage} className="btn-primary px-4">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 gap-3">
|
||||
<Plus className="w-10 h-10" />
|
||||
<p>Search for a user to start a conversation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore } from '../lib/store';
|
||||
import {
|
||||
generateKdfSalt,
|
||||
deriveKeyFromPassword,
|
||||
generateMasterKey,
|
||||
encryptMasterKey,
|
||||
} from '../crypto';
|
||||
import { socket } from '../lib/ws';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth, setMasterKey, setUsername } = useAuthStore();
|
||||
const [username, setUsernameField] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirm) { setError('Passwords do not match'); return; }
|
||||
if (password.length < 12) { setError('Password must be at least 12 characters'); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Generate all crypto material client-side
|
||||
const kdfSalt = await generateKdfSalt();
|
||||
const derivedKey = await deriveKeyFromPassword(password, kdfSalt);
|
||||
const masterKey = await generateMasterKey();
|
||||
const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey);
|
||||
|
||||
// Minimal public key placeholder (full ECDH exchange would use X25519 via libsodium)
|
||||
const publicKey = 'placeholder-pubkey';
|
||||
const keySignature = 'placeholder-sig';
|
||||
|
||||
const res = await api.post('/auth/register', {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
kdfSalt,
|
||||
encryptedMasterKey,
|
||||
publicKey,
|
||||
keySignature,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, userId } = res.data;
|
||||
setAuth({ accessToken, refreshToken, userId });
|
||||
setMasterKey(masterKey);
|
||||
setUsername(username);
|
||||
|
||||
socket.connect(accessToken);
|
||||
navigate('/messages');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<Shield className="w-12 h-12 text-accent mb-3" />
|
||||
<h1 className="text-2xl font-bold">CloudLink</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">Create your encrypted account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
|
||||
<h2 className="font-semibold text-lg">Create account</h2>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<Field label="Username" type="text" value={username} onChange={setUsernameField} />
|
||||
<Field label="Email" type="email" value={email} onChange={setEmail} />
|
||||
<Field label="Password (min 12 chars)" type="password" value={password} onChange={setPassword} />
|
||||
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} />
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Your password derives the encryption key. If you forget it, your data cannot be recovered.
|
||||
</p>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-slate-400 mt-4">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, type, value, onChange }: {
|
||||
label: string; type: string; value: string; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">{label}</label>
|
||||
<input className="input w-full" type={type} value={value} onChange={(e) => onChange(e.target.value)} required />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Shield, KeyRound, Copy, Check } from 'lucide-react';
|
||||
import api from '../lib/api';
|
||||
|
||||
interface Me {
|
||||
username: string;
|
||||
email: string;
|
||||
totp_enabled: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: me } = useQuery<Me>({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => api.get('/me').then((r) => r.data),
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<'idle' | 'setup' | 'verify' | 'done'>('idle');
|
||||
const [qr, setQr] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function setup2fa() {
|
||||
const res = await api.post('/auth/2fa/setup');
|
||||
setQr(res.data.qrDataUrl);
|
||||
setSecret(res.data.secret);
|
||||
setStep('setup');
|
||||
}
|
||||
|
||||
async function verify2fa() {
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.post('/auth/2fa/verify', { code });
|
||||
setBackupCodes(res.data.backupCodes);
|
||||
setStep('done');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-xl mx-auto p-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
|
||||
{/* Account info */}
|
||||
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-3">
|
||||
<h2 className="font-medium flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-accent" /> Account
|
||||
</h2>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="text-slate-400">Username: <span className="text-slate-100">{me?.username}</span></p>
|
||||
<p className="text-slate-400">Email: <span className="text-slate-100">{me?.email}</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2FA */}
|
||||
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-4">
|
||||
<h2 className="font-medium flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4 text-accent" /> Two-factor authentication
|
||||
</h2>
|
||||
|
||||
{me?.totp_enabled && step === 'idle' ? (
|
||||
<p className="text-sm text-green-400 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" /> 2FA is enabled
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!me?.totp_enabled && step === 'idle' && (
|
||||
<button onClick={setup2fa} className="btn-primary text-sm">
|
||||
Set up 2FA
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 'setup' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-400">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<img src={qr} alt="2FA QR code" className="w-48 h-48 rounded-lg" />
|
||||
<p className="text-xs text-slate-500 font-mono break-all">{secret}</p>
|
||||
<button onClick={() => setStep('verify')} className="btn-primary text-sm">
|
||||
I've scanned it
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'verify' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-400">Enter the 6-digit code from your app:</p>
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<input
|
||||
className="input w-40 font-mono text-lg tracking-widest"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={verify2fa} className="btn-primary text-sm">Verify</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-green-400">2FA enabled! Save these backup codes:</p>
|
||||
<div className="bg-surface-2 rounded-lg p-3 font-mono text-sm space-y-1">
|
||||
{backupCodes.map((c) => <p key={c}>{c}</p>)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copy(backupCodes.join('\n'))}
|
||||
className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? 'Copied' : 'Copy codes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Encryption notice */}
|
||||
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5">
|
||||
<h2 className="font-medium flex items-center gap-2 mb-3">
|
||||
<Shield className="w-4 h-4 text-accent" /> Encryption
|
||||
</h2>
|
||||
<ul className="text-sm text-slate-400 space-y-1.5 list-disc list-inside">
|
||||
<li>All messages and files are encrypted in your browser before being sent</li>
|
||||
<li>Your encryption key is derived from your password — never sent to the server</li>
|
||||
<li>The server operator cannot read your data</li>
|
||||
<li>If you forget your password, your data cannot be recovered</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user