diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d82e72..dad95f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel"; import { ReviewPanel } from "./components/review/ReviewPanel"; import { Tabs, TabsContent } from "./components/ui/tabs"; import { VoicePanel } from "./components/voice/VoicePanel"; +import { AuthOverlay } from "./components/layout/AuthOverlay"; import { useDashboardSocket } from "./hooks/useDashboardSocket"; import { mergeMessages, useMessages } from "./hooks/useMessages"; import { useMediaControl } from "./hooks/useMediaControl"; @@ -26,6 +27,7 @@ export default function App() { const [levels, setLevels] = useState(Array.from({ length: 32 }, () => 0.04)); const [isListening, setIsListening] = useState(false); const [isStreaming, setIsStreaming] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password")); const audioContextListenRef = useRef(null); const audioContextTransmitRef = useRef(null); const streamRef = useRef(null); @@ -198,34 +200,42 @@ export default function App() { patchUIState({ activeTab: value as DashboardTab })}> - patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })} - onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })} - onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)} - onDisconnect={() => voice.leaveVoice()} - onListenToggle={toggleListening} - onStreamingToggle={toggleStreaming} - /> + {!isAuthenticated ? ( + setIsAuthenticated(true)} /> + ) : ( + patchUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: "" })} + onChannelChange={(channelId) => patchUIState({ selectedVoiceChannel: channelId })} + onJoin={() => voice.joinVoice(selectedVoiceGuild, selectedVoiceChannel)} + onDisconnect={() => voice.leaveVoice()} + onListenToggle={toggleListening} + onStreamingToggle={toggleStreaming} + /> + )} - media.enqueue(source, "music")} - onStartScreen={(source) => media.enqueue(source, "screen")} - onSkip={media.skip} - onStop={media.stop} - /> + {!isAuthenticated ? ( + setIsAuthenticated(true)} /> + ) : ( + media.enqueue(source, "music")} + onStartScreen={(source) => media.enqueue(source, "screen")} + onSkip={media.skip} + onStop={media.stop} + /> + )} { + return request<{ ok: boolean }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ password }), + }); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 05b3bb3..7b18e69 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -50,8 +50,12 @@ class ApiError extends Error { } export async function request(path: string, init?: RequestInit): Promise { + const password = localStorage.getItem("admin-password"); const res = await fetch(path, { - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(password ? { "X-Admin-Password": password } : {}), + }, ...init, }); diff --git a/frontend/src/components/layout/AuthOverlay.tsx b/frontend/src/components/layout/AuthOverlay.tsx new file mode 100644 index 0000000..d67977a --- /dev/null +++ b/frontend/src/components/layout/AuthOverlay.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { login } from "../../api/auth"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Input } from "../ui/input"; +import { Lock } from "lucide-react"; + +interface AuthOverlayProps { + onAuthenticated: () => void; +} + +export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + await login(password); + localStorage.setItem("admin-password", password); + onAuthenticated(); + } catch (err) { + setError("Invalid password"); + } finally { + setLoading(false); + } + }; + + return ( +
+ + +
+ +
+ Admin Access Required + Enter the admin password to access Voice and Media controls. +
+ +
+
+ setPassword(e.target.value)} + autoFocus + /> + {error &&

{error}

} +
+ +
+
+
+
+ ); +} diff --git a/src/config.ts b/src/config.ts index 33390af..13ff1e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,7 @@ const configSchema = z POSTGRES_DB: z.string().optional(), POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), + ADMIN_PASSWORD: z.string().default("admin123"), }) .superRefine((value, ctx) => { if (!value.AI_ANALYSIS_ENABLED) { diff --git a/src/webserver.ts b/src/webserver.ts index a95a14a..9410b25 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { Streamer } from "@dank074/discord-video-stream"; import { AudioPlayerStatus } from "@discordjs/voice"; import type { Client } from "discord.js-selfbot-v13"; +import { config } from "./config"; import express, { type NextFunction, type Request, @@ -257,6 +258,25 @@ export async function startWebserver( res.send(await getMetrics()); }); + // Simple password-based auth + app.post("/api/auth/login", (req: Request, res: Response) => { + const { password } = req.body; + if (password === config.ADMIN_PASSWORD) { + res.json({ ok: true }); + } else { + res.status(401).json({ error: "Invalid password" }); + } + }); + + const adminAuth = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers["x-admin-password"]; + if (authHeader === config.ADMIN_PASSWORD) { + next(); + } else { + res.status(401).json({ error: "Unauthorized access to admin features" }); + } + }; + // Register route modules app.use( "/api", @@ -264,6 +284,7 @@ export async function startWebserver( ); app.use( "/api", + adminAuth, createVoiceRoutes({ voiceController, patchSharedUIState, @@ -273,7 +294,7 @@ export async function startWebserver( app.use("/api", createMessageRoutes()); app.use("/api", createAnalysisRoutes()); app.use("/api", createSyncRoutes(_client)); - app.use("/api", createMediaRoutes(mediaController)); + app.use("/api", adminAuth, createMediaRoutes(mediaController)); // Inbound: Discord PCM → tagged chunks → browser (globalThis as VoiceGlobals).broadcastPcmToWeb = (