Skip to content

Commit 6d5fdd4

Browse files
committed
Merge branch 'refactor'
2 parents e23959f + dc4536c commit 6d5fdd4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+1862
-1184
lines changed

frontend/plugins/optimizeSvg.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Plugin } from 'vite';
2+
import { optimize } from 'svgo';
3+
4+
export interface OptimizeSvgOptions {
5+
/**
6+
* Whether to enable SVG optimization
7+
* @default true
8+
*/
9+
enabled?: boolean;
10+
}
11+
12+
const svgoConfig: Parameters<typeof optimize>[1] = {
13+
multipass: true,
14+
plugins: [
15+
{
16+
name: 'preset-default',
17+
params: {
18+
overrides: {
19+
// Keep IDs if they might be referenced (minify instead of remove)
20+
cleanupIds: {
21+
remove: false,
22+
minify: true
23+
}
24+
}
25+
}
26+
}
27+
]
28+
};
29+
30+
/**
31+
* Optimizes SVG files during build by:
32+
* - Minifying SVG code
33+
* - Removing metadata and comments
34+
* - Removing unnecessary attributes
35+
* - Optimizing paths and shapes
36+
*/
37+
export function optimizeSvg(options?: OptimizeSvgOptions): Plugin {
38+
const enabled = options?.enabled !== false;
39+
40+
return {
41+
name: 'optimize-svg',
42+
apply: 'build',
43+
enforce: 'post',
44+
async generateBundle(options, bundle) {
45+
if (!enabled) return;
46+
47+
// Optimize SVGs in the bundle
48+
for (const [fileName, chunk] of Object.entries(bundle)) {
49+
if (fileName.endsWith('.svg') && chunk.type === 'asset') {
50+
try {
51+
const svgContent = typeof chunk.source === 'string'
52+
? chunk.source
53+
: Buffer.from(chunk.source).toString('utf-8');
54+
55+
const result = optimize(svgContent, svgoConfig);
56+
57+
if (result.data && result.data !== svgContent) {
58+
chunk.source = result.data;
59+
}
60+
} catch (error) {
61+
console.warn(`Failed to optimize SVG ${fileName}:`, error);
62+
}
63+
}
64+
}
65+
}
66+
};
67+
}

frontend/src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
22
import { AnimatePresence, motion } from "motion/react";
33
import { ElectronTitleBar } from "./Electron";
4-
import { useAppState } from "./pages/chat/state";
4+
import { useUserStore } from "./state/user";
55
import { lazy, useEffect, useRef, useState } from "react";
66
import { parseProfileLink } from "./core/profileLinks";
77
import NotFoundPage from "./pages/not-found/NotFoundPage";
@@ -117,14 +117,14 @@ function AnimatedRoutes() {
117117
}
118118

119119
export default function App() {
120-
const { restoreUserFromStorage, user } = useAppState();
120+
const { restoreFromStorage, user } = useUserStore();
121121
const [authReady, setAuthReady] = useState(false);
122122

123123
useEffect(() => {
124-
restoreUserFromStorage().finally(() => {
124+
restoreFromStorage().finally(() => {
125125
setAuthReady(true);
126126
});
127-
}, [restoreUserFromStorage]);
127+
}, [restoreFromStorage]);
128128

129129
return authReady && (
130130
<BrowserRouter>
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { API_BASE_URL } from "@/core/config";
2-
import { getAuthHeaders } from "@/core/api/authApi";
2+
import { getAuthHeaders } from "./index";
33

44
export interface DeviceInfo {
55
session_id: string;
@@ -18,20 +18,19 @@ export interface DeviceInfo {
1818
}
1919

2020
export async function listDevices(token: string): Promise<DeviceInfo[]> {
21-
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) });
21+
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
2222
if (!res.ok) throw new Error("Failed to fetch devices");
2323
const data = await res.json();
2424
return data.devices as DeviceInfo[];
2525
}
2626

2727
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
28-
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) });
28+
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
2929
if (!res.ok) throw new Error("Failed to revoke device");
3030
}
3131

3232
export async function logoutAllOtherDevices(token: string): Promise<void> {
33-
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) });
33+
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
3434
if (!res.ok) throw new Error("Failed to logout all devices");
3535
}
3636

37-
Lines changed: 118 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types";
1+
import { API_BASE_URL } from "@/core/config";
2+
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
23
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
34
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
45
import { b64, ub64 } from "@/utils/utils";
5-
import { API_BASE_URL } from "@/core/config";
66
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
7+
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
8+
import type { Headers } from "@/core/types";
79

810
/**
911
* Generates authentication headers for API requests
12+
* @param {string | null} token - Authentication token
1013
* @param {boolean} json - Whether to include JSON content type header
1114
* @returns {Headers} Headers object with authentication and content type
1215
*/
@@ -23,61 +26,25 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head
2326
return headers;
2427
}
2528

26-
let currentPublicKey: Uint8Array | null = null;
27-
let currentPrivateKey: Uint8Array | null = null;
28-
29-
async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
30-
const headers = getAuthHeaders(token, true);
31-
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
32-
if (!res.ok) return null;
33-
const data = await res.json();
34-
if (!data?.publicKey) return null;
35-
return ub64(data.publicKey);
29+
export interface CheckAuthResponse {
30+
authenticated: boolean;
31+
username: string;
32+
admin: boolean;
3633
}
3734

38-
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
39-
const payload: UploadPublicKeyRequest = {
40-
publicKey: b64(publicKey)
41-
}
42-
43-
const headers = getAuthHeaders(token, true);
44-
await fetch(`${API_BASE_URL}/crypto/public-key`, {
45-
method: "POST",
46-
headers,
47-
body: JSON.stringify(payload)
48-
});
49-
}
50-
51-
async function fetchBackupBlob(token: string): Promise<string | null> {
52-
const headers = getAuthHeaders(token, true);
53-
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
54-
method: "GET",
55-
headers
56-
});
57-
if (res.ok) {
58-
const response: BackupBlob = await res.json();
59-
return response.blob;
60-
} else {
61-
return null;
62-
}
63-
}
64-
65-
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
66-
const payload: BackupBlob = { blob: blobJson }
67-
68-
const headers = getAuthHeaders(token, true);
69-
await fetch(`${API_BASE_URL}/crypto/backup`, {
70-
method: "POST",
71-
headers,
72-
body: JSON.stringify(payload)
73-
});
35+
export interface LogoutResponse {
36+
status: string;
37+
message: string;
7438
}
7539

7640
export interface UserKeyPairMemory {
7741
publicKey: Uint8Array;
7842
privateKey: Uint8Array;
7943
}
8044

45+
let currentPublicKey: Uint8Array | null = null;
46+
let currentPrivateKey: Uint8Array | null = null;
47+
8148
export function getCurrentKeys(): UserKeyPairMemory | null {
8249
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
8350
return null;
@@ -94,6 +61,72 @@ function saveKeys(
9461
localStorage.setItem("privateKey", encodedPrivateKey);
9562
}
9663

64+
/**
65+
* Checks if the current user is authenticated
66+
*/
67+
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
68+
const res = await fetch(`${API_BASE_URL}/check_auth`, {
69+
headers: getAuthHeaders(token, true)
70+
});
71+
if (!res.ok) throw new Error("Failed to check auth");
72+
return await res.json();
73+
}
74+
75+
/**
76+
* Logs in a user with username and password
77+
*/
78+
export async function login(request: LoginRequest): Promise<LoginResponse> {
79+
const res = await fetch(`${API_BASE_URL}/login`, {
80+
method: "POST",
81+
headers: getAuthHeaders(null, true),
82+
body: JSON.stringify(request)
83+
});
84+
if (!res.ok) {
85+
const error = await res.json().catch(() => ({ detail: "Login failed" }));
86+
throw new Error(error.detail || "Login failed");
87+
}
88+
return await res.json();
89+
}
90+
91+
/**
92+
* Registers a new user
93+
*/
94+
export async function register(request: RegisterRequest): Promise<LoginResponse> {
95+
const res = await fetch(`${API_BASE_URL}/register`, {
96+
method: "POST",
97+
headers: getAuthHeaders(null, true),
98+
body: JSON.stringify(request)
99+
});
100+
if (!res.ok) {
101+
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
102+
throw new Error(error.detail || "Registration failed");
103+
}
104+
return await res.json();
105+
}
106+
107+
/**
108+
* Logs out the current user
109+
*/
110+
export async function logout(token: string): Promise<LogoutResponse> {
111+
const res = await fetch(`${API_BASE_URL}/logout`, {
112+
headers: getAuthHeaders(token, true)
113+
});
114+
if (!res.ok) throw new Error("Failed to logout");
115+
return await res.json();
116+
}
117+
118+
/**
119+
* Derive a client-side authentication secret so the raw password never leaves the client.
120+
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
121+
*/
122+
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
123+
// Use per-user salt derived from username; in future we can fetch a server-provided salt
124+
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
125+
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
126+
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
127+
return b64(derived);
128+
}
129+
97130
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
98131
// Try to restore from backup
99132
const blobJson = await fetchBackupBlob(token);
@@ -147,13 +180,41 @@ export function getAuthToken(): string | null {
147180
}
148181

149182
/**
150-
* Derive a client-side authentication secret so the raw password never leaves the client.
151-
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
183+
* Changes the user's password
152184
*/
153-
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
154-
// Use per-user salt derived from username; in future we can fetch a server-provided salt
155-
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
156-
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
157-
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
158-
return b64(derived);
159-
}
185+
export async function changePassword(
186+
token: string,
187+
username: string,
188+
currentPassword: string,
189+
newPassword: string,
190+
logoutAllExceptCurrent: boolean
191+
): Promise<void> {
192+
const currentDerived = await deriveAuthSecret(username, currentPassword);
193+
const newDerived = await deriveAuthSecret(username, newPassword);
194+
const res = await fetch(`${API_BASE_URL}/change-password`, {
195+
method: "POST",
196+
headers: getAuthHeaders(token, true),
197+
body: JSON.stringify({
198+
currentPasswordDerived: currentDerived,
199+
newPasswordDerived: newDerived,
200+
logoutAllExceptCurrent
201+
})
202+
});
203+
if (!res.ok) throw new Error("Failed to change password");
204+
}
205+
206+
/**
207+
* Deletes the current user's account
208+
*/
209+
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
210+
const res = await fetch(`${API_BASE_URL}/account/delete`, {
211+
method: "POST",
212+
headers: getAuthHeaders(token, true)
213+
});
214+
if (!res.ok) {
215+
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
216+
throw new Error(error.detail || "Failed to delete account");
217+
}
218+
return await res.json();
219+
}
220+

0 commit comments

Comments
 (0)