techedstudios commited on
Commit
4e00128
·
1 Parent(s): d6ca85a

Add admin login/setup screen + Firestore-based backups (no Storage needed)

Browse files
Files changed (4) hide show
  1. backend/firebase-config.js +1 -3
  2. backend/sync-manager.js +120 -117
  3. server.js +127 -1
  4. src/App.tsx +245 -31
backend/firebase-config.js CHANGED
@@ -1,5 +1,4 @@
1
  import { initializeApp } from "firebase/app";
2
- import { getStorage } from "firebase/storage";
3
  import { getFirestore } from "firebase/firestore";
4
 
5
  // Default config provided by the user
@@ -14,7 +13,6 @@ const defaultFirebaseConfig = {
14
  };
15
 
16
  const app = initializeApp(defaultFirebaseConfig);
17
- const storage = getStorage(app);
18
  const db = getFirestore(app);
19
 
20
- export { app, storage, db, defaultFirebaseConfig as firebaseConfig };
 
1
  import { initializeApp } from "firebase/app";
 
2
  import { getFirestore } from "firebase/firestore";
3
 
4
  // Default config provided by the user
 
13
  };
14
 
15
  const app = initializeApp(defaultFirebaseConfig);
 
16
  const db = getFirestore(app);
17
 
18
+ export { app, db, defaultFirebaseConfig as firebaseConfig };
backend/sync-manager.js CHANGED
@@ -1,106 +1,116 @@
1
- import { ref, uploadBytes, getDownloadURL, listAll, deleteObject } from "firebase/storage";
2
- import { storage } from "./firebase-config.js";
3
  import fs from "fs";
4
  import path from "path";
5
- import https from "https";
6
  import AdmZip from "adm-zip";
7
 
8
- // Helper: Download a file via https stream to a local path
9
- function downloadFile(url, destPath) {
10
- return new Promise((resolve, reject) => {
11
- const file = fs.createWriteStream(destPath);
12
- https.get(url, (response) => {
13
- if (response.statusCode !== 200) {
14
- reject(new Error(`Failed to download: ${response.statusCode}`));
15
- return;
16
- }
17
- response.pipe(file);
18
- file.on("finish", () => {
19
- file.close();
20
- resolve();
21
- });
22
- }).on("error", (err) => {
23
- fs.unlink(destPath, () => {});
24
- reject(err);
25
- });
26
- });
27
- }
28
 
29
- // Compress a directory into a .zip file
30
- function compressDirectory(sourceDir, outPath) {
31
- return new Promise((resolve, reject) => {
32
- try {
33
- const zip = new AdmZip();
34
- zip.addLocalFolder(sourceDir);
35
- zip.writeZip(outPath);
36
- resolve();
37
- } catch (e) {
38
- reject(e);
 
 
 
 
 
 
 
 
39
  }
40
- });
 
 
 
41
  }
42
 
43
- // Decompress a .zip file to a directory
44
- function decompressZip(zipPath, targetDir) {
45
- return new Promise((resolve, reject) => {
46
- try {
47
- if (!fs.existsSync(targetDir)) {
48
- fs.mkdirSync(targetDir, { recursive: true });
49
- }
50
- const zip = new AdmZip(zipPath);
51
- zip.extractAllTo(targetDir, true);
52
- resolve();
53
- } catch (e) {
54
- reject(e);
55
- }
56
- });
57
  }
58
 
59
  /**
60
- * Backup a Minecraft server folder to Firebase Storage
61
  */
62
  export async function backupServer(serverId, serverPath, onLog) {
63
- if (!fs.existsSync(serverPath)) {
64
- onLog(serverId, `[BACKUP] Server path does not exist. Skipping backup.`);
65
  return false;
66
  }
67
 
68
- const tempZip = path.join("/tmp", `${serverId}-${Date.now()}.zip`);
69
- onLog(serverId, `[BACKUP] Compressing server files...`);
70
-
71
  try {
72
- // 1. Zip local directory
73
- await compressDirectory(serverPath, tempZip);
74
- onLog(serverId, `[BACKUP] Compression complete. Size: ${(fs.statSync(tempZip).size / (1024 * 1024)).toFixed(2)} MB`);
 
 
 
 
 
75
 
76
- // 2. Upload to Firebase Storage
 
77
  const timestamp = Date.now();
78
- const backupRef = ref(storage, `backups/${serverId}/${timestamp}.zip`);
79
- onLog(serverId, `[BACKUP] Uploading backup to Firebase Storage...`);
80
-
81
- const fileBuffer = fs.readFileSync(tempZip);
82
- await uploadBytes(backupRef, fileBuffer, {
83
- contentType: "application/zip"
 
 
 
 
 
 
 
 
 
84
  });
 
 
 
 
 
 
 
85
  onLog(serverId, `[BACKUP] Upload successful!`);
86
 
87
- // 3. Clean up oldest backups (Retention: keep latest 3)
88
  try {
89
- onLog(serverId, `[BACKUP] Pruning old backups (Retention limit: 3)...`);
90
- const folderRef = ref(storage, `backups/${serverId}`);
91
- const listResult = await listAll(folderRef);
92
-
93
- // Sort items by name (timestamp is the name, so ascending order)
94
- const sortedItems = listResult.items.sort((a, b) => a.name.localeCompare(b.name));
95
-
96
- if (sortedItems.length > 3) {
97
- const itemsToDelete = sortedItems.slice(0, sortedItems.length - 3);
98
- for (const item of itemsToDelete) {
99
- onLog(serverId, `[BACKUP] Deleting expired backup: ${item.name}`);
100
- await deleteObject(item);
 
 
 
 
 
 
101
  }
 
102
  }
103
- onLog(serverId, `[BACKUP] Backup cleanup completed.`);
104
  } catch (pruneErr) {
105
  onLog(serverId, `[BACKUP] Warning: Failed to prune old backups: ${pruneErr.message}`);
106
  }
@@ -110,64 +120,57 @@ export async function backupServer(serverId, serverPath, onLog) {
110
  onLog(serverId, `[BACKUP] ERROR: Backup failed: ${err.message}`);
111
  console.error(err);
112
  return false;
113
- } finally {
114
- // Clean up temporary local zip file
115
- if (fs.existsSync(tempZip)) {
116
- try {
117
- fs.unlinkSync(tempZip);
118
- } catch (e) {}
119
- }
120
  }
121
  }
122
 
123
  /**
124
- * Restore a Minecraft server folder from Firebase Storage
125
  */
126
  export async function restoreServer(serverId, serverPath, onLog) {
127
- const folderRef = ref(storage, `backups/${serverId}`);
128
- onLog(serverId, `[RESTORE] Checking for backups in Firebase Storage...`);
129
-
130
  try {
131
- const listResult = await listAll(folderRef);
132
- if (listResult.items.length === 0) {
133
- onLog(serverId, `[RESTORE] No backups found in Firebase Storage. Will start with a clean server.`);
 
 
 
134
  return false;
135
  }
136
 
137
- // Sort items by name (timestamp) and get the latest
138
- const sortedItems = listResult.items.sort((a, b) => a.name.localeCompare(b.name));
139
- const latestItem = sortedItems[sortedItems.length - 1];
140
-
141
- onLog(serverId, `[RESTORE] Found latest backup: ${latestItem.name}. Downloading...`);
142
- const downloadUrl = await getDownloadURL(latestItem);
143
 
144
- const tempZip = path.join("/tmp", `${serverId}-restore.zip`);
145
- await downloadFile(downloadUrl, tempZip);
146
- onLog(serverId, `[RESTORE] Download complete. Extracting files...`);
 
 
 
 
147
 
148
- // Ensure server path exists and clean it (except maybe playit.toml if we want to preserve local session, but full extract overwrites anyway)
 
 
 
 
149
  if (fs.existsSync(serverPath)) {
150
- // Delete existing files to avoid conflicts, keeping directory itself
151
  const files = fs.readdirSync(serverPath);
152
  for (const file of files) {
153
  const filePath = path.join(serverPath, file);
154
- if (fs.statSync(filePath).isDirectory()) {
155
- fs.rmSync(filePath, { recursive: true, force: true });
156
- } else {
157
- fs.unlinkSync(filePath);
158
- }
 
 
159
  }
160
- } else {
161
- fs.mkdirSync(serverPath, { recursive: true });
162
  }
163
 
164
- await decompressZip(tempZip, serverPath);
165
- onLog(serverId, `[RESTORE] Server files restored successfully from backup.`);
166
-
167
- // Clean up local temp zip
168
- if (fs.existsSync(tempZip)) {
169
- fs.unlinkSync(tempZip);
170
- }
171
  return true;
172
  } catch (err) {
173
  onLog(serverId, `[RESTORE] ERROR: Restore failed: ${err.message}`);
 
1
+ import { doc, getDoc, setDoc, deleteDoc, collection, getDocs } from "firebase/firestore";
2
+ import { db } from "./firebase-config.js";
3
  import fs from "fs";
4
  import path from "path";
 
5
  import AdmZip from "adm-zip";
6
 
7
+ const CHUNK_SIZE = 900 * 1024; // 900KB per Firestore document chunk (limit is ~1MB)
8
+ const MAX_BACKUPS = 3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ // Compress a directory into a Buffer
11
+ function compressDirectoryToBuffer(sourceDir) {
12
+ const zip = new AdmZip();
13
+ // Add all files except large binaries (playit, java, downloads)
14
+ const skipNames = ['playit', 'playit.exe', 'downloads', 'tmp'];
15
+
16
+ function addDir(dirPath, zipPath) {
17
+ const entries = fs.readdirSync(dirPath);
18
+ for (const entry of entries) {
19
+ if (skipNames.includes(entry)) continue;
20
+ const fullPath = path.join(dirPath, entry);
21
+ const stat = fs.statSync(fullPath);
22
+ if (stat.isDirectory()) {
23
+ addDir(fullPath, path.join(zipPath, entry));
24
+ } else {
25
+ const content = fs.readFileSync(fullPath);
26
+ zip.addFile(path.join(zipPath, entry).replace(/\\/g, '/'), content);
27
+ }
28
  }
29
+ }
30
+
31
+ addDir(sourceDir, '');
32
+ return zip.toBuffer();
33
  }
34
 
35
+ // Decompress a Buffer into a directory
36
+ function decompressBufferToDir(buffer, targetDir) {
37
+ if (!fs.existsSync(targetDir)) {
38
+ fs.mkdirSync(targetDir, { recursive: true });
39
+ }
40
+ const zip = new AdmZip(buffer);
41
+ zip.extractAllTo(targetDir, true);
 
 
 
 
 
 
 
42
  }
43
 
44
  /**
45
+ * Backup a Minecraft server folder to Firestore (chunked Base64)
46
  */
47
  export async function backupServer(serverId, serverPath, onLog) {
48
+ if (!fs.existsSync(serverPath) || fs.readdirSync(serverPath).length === 0) {
49
+ onLog(serverId, `[BACKUP] Server path empty or missing. Skipping backup.`);
50
  return false;
51
  }
52
 
 
 
 
53
  try {
54
+ onLog(serverId, `[BACKUP] Compressing server files...`);
55
+ const zipBuffer = compressDirectoryToBuffer(serverPath);
56
+ const sizeMB = (zipBuffer.length / (1024 * 1024)).toFixed(2);
57
+ onLog(serverId, `[BACKUP] Compression complete. Size: ${sizeMB} MB`);
58
+
59
+ if (zipBuffer.length > 50 * 1024 * 1024) {
60
+ onLog(serverId, `[BACKUP] WARNING: Backup is ${sizeMB}MB. Firestore free tier stores up to ~1GB total. Monitor usage.`);
61
+ }
62
 
63
+ // Convert to Base64 string
64
+ const base64Data = zipBuffer.toString('base64');
65
  const timestamp = Date.now();
66
+
67
+ // Split into chunks
68
+ const chunks = [];
69
+ for (let i = 0; i < base64Data.length; i += CHUNK_SIZE) {
70
+ chunks.push(base64Data.slice(i, i + CHUNK_SIZE));
71
+ }
72
+ onLog(serverId, `[BACKUP] Uploading ${chunks.length} chunk(s) to Firestore...`);
73
+
74
+ // Store metadata
75
+ const metaRef = doc(db, `backups/${serverId}/list/${timestamp}`);
76
+ await setDoc(metaRef, {
77
+ timestamp,
78
+ chunkCount: chunks.length,
79
+ size: zipBuffer.length,
80
+ createdAt: new Date().toISOString()
81
  });
82
+
83
+ // Store chunks
84
+ for (let i = 0; i < chunks.length; i++) {
85
+ const chunkRef = doc(db, `backups/${serverId}/data/${timestamp}_chunk_${i}`);
86
+ await setDoc(chunkRef, { chunk: chunks[i], index: i, backupId: String(timestamp) });
87
+ }
88
+
89
  onLog(serverId, `[BACKUP] Upload successful!`);
90
 
91
+ // Prune old backups (keep latest MAX_BACKUPS)
92
  try {
93
+ const listRef = collection(db, `backups/${serverId}/list`);
94
+ const snapshot = await getDocs(listRef);
95
+ const allBackups = snapshot.docs.map(d => ({ id: d.id, ...d.data() }))
96
+ .sort((a, b) => a.timestamp - b.timestamp);
97
+
98
+ if (allBackups.length > MAX_BACKUPS) {
99
+ const toDelete = allBackups.slice(0, allBackups.length - MAX_BACKUPS);
100
+ for (const backup of toDelete) {
101
+ onLog(serverId, `[BACKUP] Pruning old backup: ${backup.id}`);
102
+ // Delete all chunks for this backup
103
+ const dataRef = collection(db, `backups/${serverId}/data`);
104
+ const dataSnap = await getDocs(dataRef);
105
+ for (const chunkDoc of dataSnap.docs) {
106
+ if (chunkDoc.data().backupId === String(backup.timestamp)) {
107
+ await deleteDoc(chunkDoc.ref);
108
+ }
109
+ }
110
+ await deleteDoc(doc(db, `backups/${serverId}/list/${backup.id}`));
111
  }
112
+ onLog(serverId, `[BACKUP] Pruned ${toDelete.length} old backup(s).`);
113
  }
 
114
  } catch (pruneErr) {
115
  onLog(serverId, `[BACKUP] Warning: Failed to prune old backups: ${pruneErr.message}`);
116
  }
 
120
  onLog(serverId, `[BACKUP] ERROR: Backup failed: ${err.message}`);
121
  console.error(err);
122
  return false;
 
 
 
 
 
 
 
123
  }
124
  }
125
 
126
  /**
127
+ * Restore a Minecraft server folder from Firestore
128
  */
129
  export async function restoreServer(serverId, serverPath, onLog) {
 
 
 
130
  try {
131
+ onLog(serverId, `[RESTORE] Checking for backups in Firestore...`);
132
+ const listRef = collection(db, `backups/${serverId}/list`);
133
+ const snapshot = await getDocs(listRef);
134
+
135
+ if (snapshot.empty) {
136
+ onLog(serverId, `[RESTORE] No backups found in Firestore. Starting fresh.`);
137
  return false;
138
  }
139
 
140
+ const allBackups = snapshot.docs.map(d => ({ id: d.id, ...d.data() }))
141
+ .sort((a, b) => a.timestamp - b.timestamp);
142
+ const latest = allBackups[allBackups.length - 1];
143
+ onLog(serverId, `[RESTORE] Found backup from ${new Date(latest.timestamp).toLocaleString()} (${latest.chunkCount} chunk(s)).`);
 
 
144
 
145
+ // Fetch all chunks
146
+ const dataRef = collection(db, `backups/${serverId}/data`);
147
+ const dataSnap = await getDocs(dataRef);
148
+ const chunks = dataSnap.docs
149
+ .filter(d => d.data().backupId === String(latest.timestamp))
150
+ .sort((a, b) => a.data().index - b.data().index)
151
+ .map(d => d.data().chunk);
152
 
153
+ onLog(serverId, `[RESTORE] Downloading and assembling ${chunks.length} chunk(s)...`);
154
+ const base64Data = chunks.join('');
155
+ const zipBuffer = Buffer.from(base64Data, 'base64');
156
+
157
+ // Clean existing files
158
  if (fs.existsSync(serverPath)) {
 
159
  const files = fs.readdirSync(serverPath);
160
  for (const file of files) {
161
  const filePath = path.join(serverPath, file);
162
+ try {
163
+ if (fs.statSync(filePath).isDirectory()) {
164
+ fs.rmSync(filePath, { recursive: true, force: true });
165
+ } else {
166
+ fs.unlinkSync(filePath);
167
+ }
168
+ } catch (e) {}
169
  }
 
 
170
  }
171
 
172
+ decompressBufferToDir(zipBuffer, serverPath);
173
+ onLog(serverId, `[RESTORE] Server files restored successfully from Firestore backup.`);
 
 
 
 
 
174
  return true;
175
  } catch (err) {
176
  onLog(serverId, `[RESTORE] ERROR: Restore failed: ${err.message}`);
server.js CHANGED
@@ -10,6 +10,10 @@ import { processManager } from './backend/process-manager.js';
10
  import { backupServer, restoreServer } from './backend/sync-manager.js';
11
  import { doc, getDoc, setDoc } from 'firebase/firestore';
12
  import { db } from './backend/firebase-config.js';
 
 
 
 
13
 
14
  const __filename = fileURLToPath(import.meta.url);
15
  const __dirname = path.dirname(__filename);
@@ -183,6 +187,48 @@ async function syncToFirestore(servers) {
183
  }
184
  }
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  // Helper: load servers
187
  function getServers() {
188
  try {
@@ -285,6 +331,74 @@ setInterval(async () => {
285
 
286
  // --- REST API Endpoints ---
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  // GET /api/servers
289
  app.get('/api/servers', async (req, res) => {
290
  const servers = getServers();
@@ -985,7 +1099,19 @@ app.post('/api/servers/:id/modrinth/uninstall', (req, res) => {
985
  });
986
 
987
  // --- WebSocket Gateway Connection Handler ---
988
- wss.on('connection', (ws) => {
 
 
 
 
 
 
 
 
 
 
 
 
989
  // Client messages
990
  ws.on('message', (messageText) => {
991
  try {
 
10
  import { backupServer, restoreServer } from './backend/sync-manager.js';
11
  import { doc, getDoc, setDoc } from 'firebase/firestore';
12
  import { db } from './backend/firebase-config.js';
13
+ import crypto from 'crypto';
14
+
15
+ const activeSessions = new Set();
16
+ let cachedAuthConfig = null;
17
 
18
  const __filename = fileURLToPath(import.meta.url);
19
  const __dirname = path.dirname(__filename);
 
187
  }
188
  }
189
 
190
+ // Auth Config helpers
191
+ async function getAuthConfig() {
192
+ if (cachedAuthConfig) return cachedAuthConfig;
193
+ try {
194
+ const docRef = doc(db, "config", "auth");
195
+ const docSnap = await getDoc(docRef);
196
+ if (docSnap.exists()) {
197
+ cachedAuthConfig = docSnap.data();
198
+ return cachedAuthConfig;
199
+ }
200
+ } catch (e) {
201
+ console.error('[AUTH] Failed to fetch auth config from Firestore:', e.message);
202
+ }
203
+ return null;
204
+ }
205
+
206
+ async function saveAuthConfig(passwordHash, salt) {
207
+ try {
208
+ const docRef = doc(db, "config", "auth");
209
+ const configData = { passwordHash, salt };
210
+ await setDoc(docRef, configData);
211
+ cachedAuthConfig = configData;
212
+ console.log('[AUTH] Successfully updated auth config in Firestore.');
213
+ return true;
214
+ } catch (e) {
215
+ console.error('[AUTH] Failed to save auth config to Firestore:', e.message);
216
+ return false;
217
+ }
218
+ }
219
+
220
+ function verifySession(req, res, next) {
221
+ const authHeader = req.headers.authorization;
222
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
223
+ return res.status(401).json({ error: 'Unauthorized. Missing authentication token.' });
224
+ }
225
+ const token = authHeader.substring(7);
226
+ if (!activeSessions.has(token)) {
227
+ return res.status(401).json({ error: 'Unauthorized. Invalid or expired session.' });
228
+ }
229
+ next();
230
+ }
231
+
232
  // Helper: load servers
233
  function getServers() {
234
  try {
 
331
 
332
  // --- REST API Endpoints ---
333
 
334
+ // --- Public Authentication Routes ---
335
+
336
+ // GET /api/auth/status
337
+ app.get('/api/auth/status', async (req, res) => {
338
+ const config = await getAuthConfig();
339
+ res.json({ setupRequired: !config || !config.passwordHash });
340
+ });
341
+
342
+ // POST /api/auth/setup
343
+ app.post('/api/auth/setup', async (req, res) => {
344
+ const { password } = req.body;
345
+ if (!password || password.length < 6) {
346
+ return res.status(400).json({ error: 'Password must be at least 6 characters long.' });
347
+ }
348
+
349
+ const config = await getAuthConfig();
350
+ if (config && config.passwordHash) {
351
+ return res.status(400).json({ error: 'Setup has already been completed.' });
352
+ }
353
+
354
+ const salt = crypto.randomBytes(16).toString('hex');
355
+ const hash = crypto.scryptSync(password, salt, 64).toString('hex');
356
+
357
+ const success = await saveAuthConfig(hash, salt);
358
+ if (success) {
359
+ const token = crypto.randomBytes(32).toString('hex');
360
+ activeSessions.add(token);
361
+ res.json({ success: true, token });
362
+ } else {
363
+ res.status(500).json({ error: 'Failed to save setup config to Firestore.' });
364
+ }
365
+ });
366
+
367
+ // POST /api/auth/login
368
+ app.post('/api/auth/login', async (req, res) => {
369
+ const { password } = req.body;
370
+ if (!password) {
371
+ return res.status(400).json({ error: 'Password is required.' });
372
+ }
373
+
374
+ const config = await getAuthConfig();
375
+ if (!config || !config.passwordHash) {
376
+ return res.status(400).json({ error: 'Setup is required first.' });
377
+ }
378
+
379
+ const hash = crypto.scryptSync(password, config.salt, 64).toString('hex');
380
+ if (hash === config.passwordHash) {
381
+ const token = crypto.randomBytes(32).toString('hex');
382
+ activeSessions.add(token);
383
+ res.json({ success: true, token });
384
+ } else {
385
+ res.status(401).json({ error: 'Incorrect password.' });
386
+ }
387
+ });
388
+
389
+ // POST /api/auth/logout
390
+ app.post('/api/auth/logout', (req, res) => {
391
+ const authHeader = req.headers.authorization;
392
+ if (authHeader && authHeader.startsWith('Bearer ')) {
393
+ const token = authHeader.substring(7);
394
+ activeSessions.delete(token);
395
+ }
396
+ res.json({ success: true });
397
+ });
398
+
399
+ // Apply session verification middleware for all server management API routes
400
+ app.use('/api/servers', verifySession);
401
+
402
  // GET /api/servers
403
  app.get('/api/servers', async (req, res) => {
404
  const servers = getServers();
 
1099
  });
1100
 
1101
  // --- WebSocket Gateway Connection Handler ---
1102
+ wss.on('connection', (ws, req) => {
1103
+ try {
1104
+ const parsedUrl = new URL(req.url, 'http://localhost');
1105
+ const token = parsedUrl.searchParams.get('token');
1106
+ if (!token || !activeSessions.has(token)) {
1107
+ ws.close(4001, 'Unauthorized');
1108
+ return;
1109
+ }
1110
+ } catch (e) {
1111
+ ws.close(4000, 'Invalid Request');
1112
+ return;
1113
+ }
1114
+
1115
  // Client messages
1116
  ws.on('message', (messageText) => {
1117
  try {
src/App.tsx CHANGED
@@ -27,7 +27,13 @@ import {
27
  Download,
28
  Trash2,
29
  CheckCircle2,
30
- HardDrive
 
 
 
 
 
 
31
  } from 'lucide-react';
32
  import { motion, AnimatePresence } from 'motion/react';
33
 
@@ -85,7 +91,177 @@ const WS_BASE = window.location.port === '3000'
85
  ? 'ws://localhost:3001'
86
  : `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`;
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  export default function App() {
 
89
  const [activeTab, setActiveTab] = useState<Tab>('dashboard');
90
  const [servers, setServers] = useState<ServerInstance[]>([]);
91
  const [currentServerId, setCurrentServerId] = useState<string | null>(null);
@@ -104,10 +280,27 @@ export default function App() {
104
  const consoleEndRef = useRef<HTMLDivElement>(null);
105
  const wsRef = useRef<WebSocket | null>(null);
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  // Fetch servers from API
108
  const fetchServers = async (selectNewId?: string) => {
109
  try {
110
- const res = await fetch(`${API_BASE}/api/servers`);
 
111
  if (res.ok) {
112
  const data = await res.json();
113
  setServers(data);
@@ -128,8 +321,8 @@ export default function App() {
128
  };
129
 
130
  useEffect(() => {
131
- fetchServers();
132
- }, []);
133
 
134
  // Sync state if active server changes
135
  const activeServer = servers.find((s) => s.id === currentServerId);
@@ -154,7 +347,8 @@ export default function App() {
154
 
155
  setLogs([]);
156
 
157
- const ws = new WebSocket(WS_BASE);
 
158
  wsRef.current = ws;
159
 
160
  ws.onopen = () => {
@@ -208,7 +402,7 @@ export default function App() {
208
  if (!currentServerId) return;
209
  setStatus('starting');
210
  try {
211
- await fetch(`${API_BASE}/api/servers/${currentServerId}/start`, { method: 'POST' });
212
  fetchServers();
213
  } catch (e) {
214
  console.error(e);
@@ -219,7 +413,7 @@ export default function App() {
219
  if (!currentServerId) return;
220
  setStatus('stopping');
221
  try {
222
- await fetch(`${API_BASE}/api/servers/${currentServerId}/stop`, { method: 'POST' });
223
  fetchServers();
224
  } catch (e) {
225
  console.error(e);
@@ -230,7 +424,7 @@ export default function App() {
230
  if (!currentServerId) return;
231
  setStatus('starting');
232
  try {
233
- await fetch(`${API_BASE}/api/servers/${currentServerId}/restart`, { method: 'POST' });
234
  fetchServers();
235
  } catch (e) {
236
  console.error(e);
@@ -242,7 +436,7 @@ export default function App() {
242
  if (!confirm('Are you sure you want to delete this server instance permanently? This will erase all server files!')) return;
243
 
244
  try {
245
- const res = await fetch(`${API_BASE}/api/servers/${currentServerId}`, { method: 'DELETE' });
246
  if (res.ok) {
247
  fetchServers();
248
  } else {
@@ -257,9 +451,8 @@ export default function App() {
257
  const handleUpdateSettings = async (settings: { customAddress: string }) => {
258
  if (!currentServerId) return;
259
  try {
260
- const res = await fetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
261
  method: 'POST',
262
- headers: { 'Content-Type': 'application/json' },
263
  body: JSON.stringify(settings)
264
  });
265
  if (res.ok) {
@@ -276,9 +469,8 @@ export default function App() {
276
  const handleSaveHeaderAddr = async () => {
277
  if (!currentServerId) return;
278
  try {
279
- const res = await fetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
280
  method: 'POST',
281
- headers: { 'Content-Type': 'application/json' },
282
  body: JSON.stringify({ customAddress: headerAddrInput })
283
  });
284
  if (res.ok) {
@@ -293,6 +485,20 @@ export default function App() {
293
  }
294
  };
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  return (
297
  <div className="flex h-screen bg-[#09090b] text-zinc-100 font-sans selection:bg-emerald-500/30">
298
  {/* Sidebar */}
@@ -350,7 +556,7 @@ export default function App() {
350
  active={activeTab === 'properties'}
351
  onClick={() => setActiveTab('properties')}
352
  />
353
- <div className="mt-4 p-3 bg-zinc-900/50 rounded-xl border border-zinc-800">
354
  <div className="flex items-center justify-between text-xs text-zinc-500 mb-2">
355
  <span>Node: Localhost</span>
356
  <span className="text-emerald-500">Active</span>
@@ -359,9 +565,21 @@ export default function App() {
359
  <div className="h-full bg-emerald-500 w-full rounded-full" />
360
  </div>
361
  </div>
 
 
 
 
 
 
 
 
 
 
 
362
  </div>
363
  </aside>
364
 
 
365
  {/* Main Content */}
366
  <main className="flex-1 flex flex-col overflow-hidden relative">
367
  {/* Status Header */}
@@ -1011,9 +1229,9 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
1011
  setSyncing(true);
1012
  addLog(`[SYSTEM] Manual Firebase cloud backup triggered...`);
1013
  try {
1014
- const res = await fetch(`${API_BASE}/api/servers/${server.id}/backup`, { method: 'POST' });
1015
  if (res.ok) {
1016
- addLog(`[SYSTEM] Manual backup successfully uploaded to Firebase Storage.`);
1017
  alert('Backup created successfully on Firebase Storage!');
1018
  } else {
1019
  const err = await res.json();
@@ -1041,10 +1259,10 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
1041
  setSyncing(true);
1042
  addLog(`[SYSTEM] Manual Firebase cloud restore triggered...`);
1043
  try {
1044
- const res = await fetch(`${API_BASE}/api/servers/${server.id}/restore`, { method: 'POST' });
1045
  if (res.ok) {
1046
  addLog(`[SYSTEM] Manual restore successfully completed.`);
1047
- alert('Server files restored successfully from latest Firebase backup!');
1048
  } else {
1049
  const err = await res.json();
1050
  addLog(`[SYSTEM] Restore failed: ${err.error}`);
@@ -1062,7 +1280,7 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
1062
  setSyncing(true);
1063
  addLog(`[SYSTEM] Triggering git pull updates manually...`);
1064
  try {
1065
- const res = await fetch(`${API_BASE}/api/servers/${server.id}/git/pull`, { method: 'POST' });
1066
  if (res.ok) {
1067
  alert('Git pull completed successfully.');
1068
  } else {
@@ -1080,7 +1298,7 @@ function PlayersComponent({ serverId, onlinePlayers, executeCommand }: { serverI
1080
  setSyncing(true);
1081
  addLog(`[SYSTEM] Triggering git backup push manually...`);
1082
  try {
1083
- const res = await fetch(`${API_BASE}/api/servers/${server.id}/git/push`, { method: 'POST' });
1084
  if (res.ok) {
1085
  alert('Git push backup completed successfully.');
1086
  } else {
@@ -1251,7 +1469,7 @@ function ServerPropertiesComponent({ server }: { server: ServerInstance }) {
1251
  useEffect(() => {
1252
  const fetchProps = async () => {
1253
  try {
1254
- const res = await fetch(`${API_BASE}/api/servers/${server.id}/properties`);
1255
  if (res.ok) {
1256
  const data = await res.json();
1257
  setProperties(data);
@@ -1270,9 +1488,8 @@ function ServerPropertiesComponent({ server }: { server: ServerInstance }) {
1270
  const handleSave = async () => {
1271
  setSaving(true);
1272
  try {
1273
- await fetch(`${API_BASE}/api/servers/${server.id}/properties`, {
1274
  method: 'POST',
1275
- headers: { 'Content-Type': 'application/json' },
1276
  body: JSON.stringify(properties)
1277
  });
1278
  alert('Properties saved successfully! Please restart the server for changes to take effect.');
@@ -1427,9 +1644,8 @@ function CreateServerWizard({ onCreated }: { onCreated: (newId: string) => void
1427
  }
1428
  setCreating(true);
1429
  try {
1430
- const res = await fetch(`${API_BASE}/api/servers`, {
1431
  method: 'POST',
1432
- headers: { 'Content-Type': 'application/json' },
1433
  body: JSON.stringify({
1434
  name,
1435
  software,
@@ -1616,7 +1832,7 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
1616
 
1617
  const fetchInstalled = async () => {
1618
  try {
1619
- const res = await fetch(`${API_BASE}/api/servers/${serverId}/modrinth/installed`);
1620
  if (res.ok) {
1621
  const data = await res.json();
1622
  setInstalled(data);
@@ -1629,7 +1845,7 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
1629
  const executeSearch = async () => {
1630
  setLoading(true);
1631
  try {
1632
- const res = await fetch(`${API_BASE}/api/modrinth/search?query=${encodeURIComponent(query)}&project_type=${projectType}`);
1633
  if (res.ok) {
1634
  const data = await res.json();
1635
  setResults(data);
@@ -1672,9 +1888,8 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
1672
  const handleInstall = async (project: any) => {
1673
  setInstallingId(project.project_id);
1674
  try {
1675
- const res = await fetch(`${API_BASE}/api/servers/${serverId}/modrinth/install`, {
1676
  method: 'POST',
1677
- headers: { 'Content-Type': 'application/json' },
1678
  body: JSON.stringify({
1679
  projectId: project.project_id,
1680
  projectType: projectType
@@ -1697,9 +1912,8 @@ function MarketplaceComponent({ serverId }: { serverId: string }) {
1697
  const handleUninstall = async (project: any, filename: string) => {
1698
  setInstallingId(project.project_id);
1699
  try {
1700
- const res = await fetch(`${API_BASE}/api/servers/${serverId}/modrinth/uninstall`, {
1701
  method: 'POST',
1702
- headers: { 'Content-Type': 'application/json' },
1703
  body: JSON.stringify({
1704
  filename,
1705
  projectType: projectType
 
27
  Download,
28
  Trash2,
29
  CheckCircle2,
30
+ HardDrive,
31
+ Lock,
32
+ LogOut,
33
+ KeyRound,
34
+ Eye,
35
+ EyeOff,
36
+ ServerCrash
37
  } from 'lucide-react';
38
  import { motion, AnimatePresence } from 'motion/react';
39
 
 
91
  ? 'ws://localhost:3001'
92
  : `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`;
93
 
94
+ // --- Auth Helper ---
95
+ function getToken() { return localStorage.getItem('mc_panel_token') || ''; }
96
+ function setToken(t: string) { localStorage.setItem('mc_panel_token', t); }
97
+ function clearToken() { localStorage.removeItem('mc_panel_token'); }
98
+ function authHeaders(): Record<string, string> {
99
+ const t = getToken();
100
+ return t ? { 'Content-Type': 'application/json', 'Authorization': `Bearer ${t}` } : { 'Content-Type': 'application/json' };
101
+ }
102
+ function authFetch(url: string, opts: RequestInit = {}) {
103
+ const t = getToken();
104
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', ...(opts.headers as Record<string, string> || {}) };
105
+ if (t) headers['Authorization'] = `Bearer ${t}`;
106
+ return fetch(url, { ...opts, headers });
107
+ }
108
+
109
+ // --- Login Screen Component ---
110
+ function AuthScreen({ onAuth }: { onAuth: () => void }) {
111
+ const [mode, setMode] = useState<'checking' | 'login' | 'setup'>('checking');
112
+ const [password, setPassword] = useState('');
113
+ const [confirmPwd, setConfirmPwd] = useState('');
114
+ const [showPwd, setShowPwd] = useState(false);
115
+ const [error, setError] = useState('');
116
+ const [loading, setLoading] = useState(false);
117
+
118
+ useEffect(() => {
119
+ fetch(`${API_BASE}/api/auth/status`)
120
+ .then(r => r.json())
121
+ .then(data => setMode(data.setupRequired ? 'setup' : 'login'))
122
+ .catch(() => setMode('login'));
123
+ }, []);
124
+
125
+ const handleSubmit = async (e: FormEvent) => {
126
+ e.preventDefault();
127
+ setError('');
128
+ if (mode === 'setup' && password !== confirmPwd) {
129
+ setError('Passwords do not match.');
130
+ return;
131
+ }
132
+ if (password.length < 6) {
133
+ setError('Password must be at least 6 characters.');
134
+ return;
135
+ }
136
+ setLoading(true);
137
+ try {
138
+ const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/login';
139
+ const res = await fetch(`${API_BASE}${endpoint}`, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({ password })
143
+ });
144
+ const data = await res.json();
145
+ if (res.ok && data.token) {
146
+ setToken(data.token);
147
+ onAuth();
148
+ } else {
149
+ setError(data.error || 'Authentication failed.');
150
+ }
151
+ } catch (e) {
152
+ setError('Failed to connect to server. Please try again.');
153
+ } finally {
154
+ setLoading(false);
155
+ }
156
+ };
157
+
158
+ if (mode === 'checking') {
159
+ return (
160
+ <div className="flex h-screen bg-[#09090b] items-center justify-center">
161
+ <div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
162
+ </div>
163
+ );
164
+ }
165
+
166
+ return (
167
+ <div className="flex h-screen bg-[#09090b] items-center justify-center relative overflow-hidden">
168
+ {/* Background grid */}
169
+ <div className="absolute inset-0" style={{ backgroundImage: 'radial-gradient(circle at 25% 50%, rgba(16,185,129,0.06) 0%, transparent 50%), radial-gradient(circle at 75% 20%, rgba(99,102,241,0.06) 0%, transparent 50%)' }} />
170
+ <div className="absolute inset-0" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
171
+
172
+ <div className="relative z-10 w-full max-w-md px-6">
173
+ {/* Logo */}
174
+ <div className="text-center mb-8">
175
+ <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 mb-4">
176
+ <ServerCrash className="w-8 h-8 text-emerald-400" />
177
+ </div>
178
+ <h1 className="text-3xl font-bold text-white tracking-tight">MC Hoster</h1>
179
+ <p className="text-zinc-400 mt-1 text-sm">Minecraft Server Management Panel</p>
180
+ </div>
181
+
182
+ {/* Card */}
183
+ <div className="bg-zinc-900/80 backdrop-blur-xl border border-zinc-800 rounded-2xl p-8 shadow-2xl">
184
+ <div className="flex items-center gap-3 mb-6">
185
+ <div className="w-9 h-9 rounded-xl bg-zinc-800 flex items-center justify-center">
186
+ {mode === 'setup' ? <KeyRound className="w-4 h-4 text-amber-400" /> : <Lock className="w-4 h-4 text-emerald-400" />}
187
+ </div>
188
+ <div>
189
+ <h2 className="text-lg font-semibold text-white">
190
+ {mode === 'setup' ? 'Create Admin Password' : 'Admin Login'}
191
+ </h2>
192
+ <p className="text-xs text-zinc-500">
193
+ {mode === 'setup' ? 'First time setup — set your secure password' : 'Enter your admin password to continue'}
194
+ </p>
195
+ </div>
196
+ </div>
197
+
198
+ <form onSubmit={handleSubmit} className="space-y-4">
199
+ <div>
200
+ <label className="block text-xs font-medium text-zinc-400 mb-1.5">Password</label>
201
+ <div className="relative">
202
+ <input
203
+ type={showPwd ? 'text' : 'password'}
204
+ value={password}
205
+ onChange={e => setPassword(e.target.value)}
206
+ placeholder="Enter password..."
207
+ className="w-full bg-zinc-800/80 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/30 transition-all pr-10"
208
+ required
209
+ autoFocus
210
+ />
211
+ <button type="button" onClick={() => setShowPwd(p => !p)}
212
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300">
213
+ {showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
214
+ </button>
215
+ </div>
216
+ </div>
217
+
218
+ {mode === 'setup' && (
219
+ <div>
220
+ <label className="block text-xs font-medium text-zinc-400 mb-1.5">Confirm Password</label>
221
+ <input
222
+ type={showPwd ? 'text' : 'password'}
223
+ value={confirmPwd}
224
+ onChange={e => setConfirmPwd(e.target.value)}
225
+ placeholder="Confirm password..."
226
+ className="w-full bg-zinc-800/80 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500/30 transition-all"
227
+ required
228
+ />
229
+ </div>
230
+ )}
231
+
232
+ {error && (
233
+ <div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3">
234
+ <ShieldAlert className="w-4 h-4 text-red-400 shrink-0" />
235
+ <p className="text-sm text-red-400">{error}</p>
236
+ </div>
237
+ )}
238
+
239
+ <button
240
+ type="submit"
241
+ disabled={loading}
242
+ className="w-full py-3 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-black font-semibold text-sm transition-all flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
243
+ >
244
+ {loading ? (
245
+ <div className="w-4 h-4 border-2 border-black border-t-transparent rounded-full animate-spin" />
246
+ ) : (
247
+ mode === 'setup' ? <><KeyRound className="w-4 h-4" /> Set Password & Enter</> : <><Lock className="w-4 h-4" /> Login</>
248
+ )}
249
+ </button>
250
+ </form>
251
+
252
+ {mode === 'setup' && (
253
+ <p className="text-center text-xs text-zinc-600 mt-4">
254
+ Password is hashed securely and stored in Firestore.
255
+ </p>
256
+ )}
257
+ </div>
258
+ </div>
259
+ </div>
260
+ );
261
+ }
262
+
263
  export default function App() {
264
+ const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
265
  const [activeTab, setActiveTab] = useState<Tab>('dashboard');
266
  const [servers, setServers] = useState<ServerInstance[]>([]);
267
  const [currentServerId, setCurrentServerId] = useState<string | null>(null);
 
280
  const consoleEndRef = useRef<HTMLDivElement>(null);
281
  const wsRef = useRef<WebSocket | null>(null);
282
 
283
+ // Check if already authenticated on mount
284
+ useEffect(() => {
285
+ const token = getToken();
286
+ if (!token) { setIsAuthenticated(false); return; }
287
+ // Verify token is still valid
288
+ fetch(`${API_BASE}/api/servers`, { headers: { 'Authorization': `Bearer ${token}` } })
289
+ .then(r => { setIsAuthenticated(r.status !== 401); })
290
+ .catch(() => setIsAuthenticated(false));
291
+ }, []);
292
+
293
+ const handleLogout = async () => {
294
+ await authFetch(`${API_BASE}/api/auth/logout`, { method: 'POST' });
295
+ clearToken();
296
+ setIsAuthenticated(false);
297
+ };
298
+
299
  // Fetch servers from API
300
  const fetchServers = async (selectNewId?: string) => {
301
  try {
302
+ const res = await authFetch(`${API_BASE}/api/servers`);
303
+ if (res.status === 401) { clearToken(); setIsAuthenticated(false); return; }
304
  if (res.ok) {
305
  const data = await res.json();
306
  setServers(data);
 
321
  };
322
 
323
  useEffect(() => {
324
+ if (isAuthenticated) fetchServers();
325
+ }, [isAuthenticated]);
326
 
327
  // Sync state if active server changes
328
  const activeServer = servers.find((s) => s.id === currentServerId);
 
347
 
348
  setLogs([]);
349
 
350
+ const token = getToken();
351
+ const ws = new WebSocket(`${WS_BASE}${WS_BASE.includes('?') ? '&' : '?'}token=${token}`);
352
  wsRef.current = ws;
353
 
354
  ws.onopen = () => {
 
402
  if (!currentServerId) return;
403
  setStatus('starting');
404
  try {
405
+ await authFetch(`${API_BASE}/api/servers/${currentServerId}/start`, { method: 'POST' });
406
  fetchServers();
407
  } catch (e) {
408
  console.error(e);
 
413
  if (!currentServerId) return;
414
  setStatus('stopping');
415
  try {
416
+ await authFetch(`${API_BASE}/api/servers/${currentServerId}/stop`, { method: 'POST' });
417
  fetchServers();
418
  } catch (e) {
419
  console.error(e);
 
424
  if (!currentServerId) return;
425
  setStatus('starting');
426
  try {
427
+ await authFetch(`${API_BASE}/api/servers/${currentServerId}/restart`, { method: 'POST' });
428
  fetchServers();
429
  } catch (e) {
430
  console.error(e);
 
436
  if (!confirm('Are you sure you want to delete this server instance permanently? This will erase all server files!')) return;
437
 
438
  try {
439
+ const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}`, { method: 'DELETE' });
440
  if (res.ok) {
441
  fetchServers();
442
  } else {
 
451
  const handleUpdateSettings = async (settings: { customAddress: string }) => {
452
  if (!currentServerId) return;
453
  try {
454
+ const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
455
  method: 'POST',
 
456
  body: JSON.stringify(settings)
457
  });
458
  if (res.ok) {
 
469
  const handleSaveHeaderAddr = async () => {
470
  if (!currentServerId) return;
471
  try {
472
+ const res = await authFetch(`${API_BASE}/api/servers/${currentServerId}/settings`, {
473
  method: 'POST',
 
474
  body: JSON.stringify({ customAddress: headerAddrInput })
475
  });
476
  if (res.ok) {
 
485
  }
486
  };
487
 
488
+ // Show loading spinner while auth state unknown
489
+ if (isAuthenticated === null) {
490
+ return (
491
+ <div className="flex h-screen bg-[#09090b] items-center justify-center">
492
+ <div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
493
+ </div>
494
+ );
495
+ }
496
+
497
+ // Show auth screen if not logged in
498
+ if (!isAuthenticated) {
499
+ return <AuthScreen onAuth={() => setIsAuthenticated(true)} />;
500
+ }
501
+
502
  return (
503
  <div className="flex h-screen bg-[#09090b] text-zinc-100 font-sans selection:bg-emerald-500/30">
504
  {/* Sidebar */}
 
556
  active={activeTab === 'properties'}
557
  onClick={() => setActiveTab('properties')}
558
  />
559
+ <div className="mt-3 p-3 bg-zinc-900/50 rounded-xl border border-zinc-800">
560
  <div className="flex items-center justify-between text-xs text-zinc-500 mb-2">
561
  <span>Node: Localhost</span>
562
  <span className="text-emerald-500">Active</span>
 
565
  <div className="h-full bg-emerald-500 w-full rounded-full" />
566
  </div>
567
  </div>
568
+ <div className="mt-2 p-2.5 bg-blue-500/5 rounded-xl border border-blue-500/20 text-[10px] text-blue-400 flex items-center gap-2">
569
+ <Database className="w-3 h-3 shrink-0" />
570
+ <span>Firestore Backups Active</span>
571
+ </div>
572
+ <button
573
+ onClick={handleLogout}
574
+ className="mt-3 w-full flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-all text-sm"
575
+ >
576
+ <LogOut className="w-4 h-4" />
577
+ <span>Logout</span>
578
+ </button>
579
  </div>
580
  </aside>
581
 
582
+
583
  {/* Main Content */}
584
  <main className="flex-1 flex flex-col overflow-hidden relative">
585
  {/* Status Header */}
 
1229
  setSyncing(true);
1230
  addLog(`[SYSTEM] Manual Firebase cloud backup triggered...`);
1231
  try {
1232
+ const res = await authFetch(`${API_BASE}/api/servers/${server.id}/backup`, { method: 'POST' });
1233
  if (res.ok) {
1234
+ addLog(`[SYSTEM] Manual backup successfully uploaded to Firestore.`);
1235
  alert('Backup created successfully on Firebase Storage!');
1236
  } else {
1237
  const err = await res.json();
 
1259
  setSyncing(true);
1260
  addLog(`[SYSTEM] Manual Firebase cloud restore triggered...`);
1261
  try {
1262
+ const res = await authFetch(`${API_BASE}/api/servers/${server.id}/restore`, { method: 'POST' });
1263
  if (res.ok) {
1264
  addLog(`[SYSTEM] Manual restore successfully completed.`);
1265
+ alert('Server files restored successfully from latest Firestore backup!');
1266
  } else {
1267
  const err = await res.json();
1268
  addLog(`[SYSTEM] Restore failed: ${err.error}`);
 
1280
  setSyncing(true);
1281
  addLog(`[SYSTEM] Triggering git pull updates manually...`);
1282
  try {
1283
+ const res = await authFetch(`${API_BASE}/api/servers/${server.id}/git/pull`, { method: 'POST' });
1284
  if (res.ok) {
1285
  alert('Git pull completed successfully.');
1286
  } else {
 
1298
  setSyncing(true);
1299
  addLog(`[SYSTEM] Triggering git backup push manually...`);
1300
  try {
1301
+ const res = await authFetch(`${API_BASE}/api/servers/${server.id}/git/push`, { method: 'POST' });
1302
  if (res.ok) {
1303
  alert('Git push backup completed successfully.');
1304
  } else {
 
1469
  useEffect(() => {
1470
  const fetchProps = async () => {
1471
  try {
1472
+ const res = await authFetch(`${API_BASE}/api/servers/${server.id}/properties`);
1473
  if (res.ok) {
1474
  const data = await res.json();
1475
  setProperties(data);
 
1488
  const handleSave = async () => {
1489
  setSaving(true);
1490
  try {
1491
+ await authFetch(`${API_BASE}/api/servers/${server.id}/properties`, {
1492
  method: 'POST',
 
1493
  body: JSON.stringify(properties)
1494
  });
1495
  alert('Properties saved successfully! Please restart the server for changes to take effect.');
 
1644
  }
1645
  setCreating(true);
1646
  try {
1647
+ const res = await authFetch(`${API_BASE}/api/servers`, {
1648
  method: 'POST',
 
1649
  body: JSON.stringify({
1650
  name,
1651
  software,
 
1832
 
1833
  const fetchInstalled = async () => {
1834
  try {
1835
+ const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/installed`);
1836
  if (res.ok) {
1837
  const data = await res.json();
1838
  setInstalled(data);
 
1845
  const executeSearch = async () => {
1846
  setLoading(true);
1847
  try {
1848
+ const res = await authFetch(`${API_BASE}/api/modrinth/search?query=${encodeURIComponent(query)}&project_type=${projectType}`);
1849
  if (res.ok) {
1850
  const data = await res.json();
1851
  setResults(data);
 
1888
  const handleInstall = async (project: any) => {
1889
  setInstallingId(project.project_id);
1890
  try {
1891
+ const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/install`, {
1892
  method: 'POST',
 
1893
  body: JSON.stringify({
1894
  projectId: project.project_id,
1895
  projectType: projectType
 
1912
  const handleUninstall = async (project: any, filename: string) => {
1913
  setInstallingId(project.project_id);
1914
  try {
1915
+ const res = await authFetch(`${API_BASE}/api/servers/${serverId}/modrinth/uninstall`, {
1916
  method: 'POST',
 
1917
  body: JSON.stringify({
1918
  filename,
1919
  projectType: projectType