diff --git a/README.md b/README.md index df9f60c..7311842 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,27 @@ src/ │ ├── NewJobPage ← Submit a new AI job │ ├── JobDetailPage ← Job status, PR link, refinement │ └── SettingsPage ← GitHub token + OpenAI key -└── components/ +The frontend will proxy `/api/*` to the auth backend during development. + +### Encrypted secrets at rest + +The local auth backend can encrypt sensitive values (GitHub tokens and OpenAI keys) before persisting them to `auth-data.json`. To enable AES-256-GCM encryption set the `REPOMIND_ENCRYPTION_KEY` environment variable when starting the server. The key must be 32 bytes (provide as base64 or hex). + +Generate a key with OpenSSL: + +```bash +# base64 (recommended) +openssl rand -base64 32 + +# or hex +openssl rand -hex 32 +``` + +Start the backend with the key: + +```bash +REPOMIND_ENCRYPTION_KEY= GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... npm run dev:api +``` ├── Layout ← Sidebar nav + main content wrapper ├── StatusBadge ← Coloured pill for job status └── ThemeToggle ← Dark/light switch button diff --git a/server.js b/server.js index 80dde14..8bc4635 100644 --- a/server.js +++ b/server.js @@ -23,13 +23,68 @@ const defaultData = { githubUsername: "", githubToken: "", openaiKey: "", + role: "admin", totalJobs: 0, successfulPRs: 0, }, ], + jobs: [], sessions: {}, }; +// Encryption helpers — AES-256-GCM +const ENC_KEY_ENV = process.env.REPOMIND_ENCRYPTION_KEY; +let ENC_KEY = null; +if (ENC_KEY_ENV) { + try { + // Try base64, then hex, then raw + let buf = Buffer.from(ENC_KEY_ENV, "base64"); + if (buf.length !== 32) { + buf = Buffer.from(ENC_KEY_ENV, "hex"); + } + if (buf.length === 32) ENC_KEY = buf; + } catch { + try { + const buf = Buffer.from(ENC_KEY_ENV, "hex"); + if (buf.length === 32) ENC_KEY = buf; + } catch { + ENC_KEY = null; + } + } +} + +function encryptSecret(plain) { + if (!plain) return ""; + if (!ENC_KEY) return plain; // fallback: store plaintext if no key provided + try { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", ENC_KEY, iv); + const ct = Buffer.concat([cipher.update(String(plain), "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ct]).toString("base64"); + } catch (err) { + return String(plain); + } +} + +function decryptSecret(stored) { + if (!stored) return ""; + if (!ENC_KEY) return stored; // fallback: assume plaintext + try { + const buf = Buffer.from(stored, "base64"); + const iv = buf.slice(0, 12); + const tag = buf.slice(12, 28); + const ct = buf.slice(28); + const decipher = crypto.createDecipheriv("aes-256-gcm", ENC_KEY, iv); + decipher.setAuthTag(tag); + const plain = Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); + return plain; + } catch (err) { + // If decryption fails, assume the stored value was plaintext + return stored; + } +} + const loadData = async () => { try { const raw = await fs.readFile(dataFile, "utf8"); @@ -47,8 +102,10 @@ const saveData = async (data) => { const data = await loadData(); const normalizeUser = (user) => { - const { password: _password, ...safeUser } = user; + const { password: _password, githubToken: _gt, openaiKey: _ok, ...safeUser } = user; void _password; + void _gt; + void _ok; return safeUser; }; @@ -56,8 +113,9 @@ const createToken = () => crypto.randomBytes(24).toString("hex"); const getUserSettings = (user) => ({ githubUsername: user.githubUsername || "", - githubToken: user.githubToken || "", - openaiKey: user.openaiKey || "", + // Never return secrets from settings endpoint — only indicate presence + githubToken: "", + openaiKey: "", hasGithubToken: Boolean(user.githubToken), hasOpenaiKey: Boolean(user.openaiKey), }); @@ -76,12 +134,21 @@ const authMiddleware = (req, res, next) => { const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : undefined; + // If no Authorization header, try cookie 'rm_token' + let finalToken = token; + if (!finalToken && req.headers.cookie) { + const cookies = Object.fromEntries(req.headers.cookie.split(/;\s*/).map(c => { + const idx = c.indexOf('='); + return [c.slice(0, idx), decodeURIComponent(c.slice(idx+1))]; + })); + finalToken = cookies['rm_token']; + } - if (!token) { + if (!finalToken) { return res.status(401).json({ message: "Missing authorization token" }); } - const session = data.sessions[token]; + const session = data.sessions[finalToken]; if (!session) { return res.status(401).json({ message: "Invalid or expired token" }); } @@ -92,7 +159,14 @@ const authMiddleware = (req, res, next) => { } req.user = user; - req.sessionToken = token; + req.sessionToken = finalToken; + next(); +}; + +const requireAdmin = (req, res, next) => { + if (!req.user || req.user.role !== "admin") { + return res.status(403).json({ message: "Admin access required" }); + } next(); }; @@ -114,11 +188,20 @@ app.post("/auth/login", async (req, res) => { }; await saveData(data); + // Set HttpOnly cookie for auth token (secure in production) + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }; + res.cookie && res.cookie("rm_token", token, cookieOpts); + + // Also return user for client convenience (token sent via cookie) return res.json({ - token, user: normalizeUser(user), githubUsername: user.githubUsername || undefined, - githubToken: user.githubToken || undefined, + githubToken: user.githubToken ? decryptSecret(user.githubToken) : undefined, }); }); @@ -145,6 +228,7 @@ app.post("/auth/signup", async (req, res) => { githubUsername: "", githubToken: "", openaiKey: "", + role: "user", // Assign default role as user totalJobs: 0, successfulPRs: 0, }; @@ -154,12 +238,16 @@ app.post("/auth/signup", async (req, res) => { data.sessions[token] = { userId: id, createdAt: new Date().toISOString() }; await saveData(data); - return res.json({ - token, - user: normalizeUser(user), - githubUsername: undefined, - githubToken: undefined, - }); + // Set HttpOnly cookie for auth token + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }; + res.cookie && res.cookie("rm_token", token, cookieOpts); + + return res.json({ user: normalizeUser(user) }); }); app.get("/auth/me", authMiddleware, (req, res) => { @@ -178,17 +266,83 @@ app.put("/settings", authMiddleware, async (req, res) => { } if (githubToken !== undefined) { - req.user.githubToken = String(githubToken || ""); + req.user.githubToken = githubToken ? encryptSecret(githubToken) : ""; } if (openaiKey !== undefined) { - req.user.openaiKey = String(openaiKey || ""); + req.user.openaiKey = openaiKey ? encryptSecret(openaiKey) : ""; } await saveData(data); return res.json(getUserSettings(req.user)); }); +// Jobs: users create jobs; admins can see all jobs +app.post("/jobs", authMiddleware, async (req, res) => { + const { repoUrl, instruction } = req.body; + if (!repoUrl || !instruction) { + return res.status(400).json({ message: "repoUrl and instruction are required" }); + } + + const job = { + id: `job-${crypto.randomBytes(8).toString("hex")}`, + userId: req.user.id, + repoUrl, + instruction, + status: "queued", + createdAt: new Date().toISOString(), + }; + data.jobs.push(job); + await saveData(data); + return res.status(201).json(job); +}); + +app.get("/jobs", authMiddleware, (req, res) => { + if (req.user.role === "admin") { + return res.json(data.jobs || []); + } + return res.json((data.jobs || []).filter((j) => j.userId === req.user.id)); +}); + +app.get("/jobs/:id", authMiddleware, (req, res) => { + const job = (data.jobs || []).find((j) => j.id === req.params.id); + if (!job) return res.status(404).json({ message: "Job not found" }); + if (req.user.role !== "admin" && job.userId !== req.user.id) { + return res.status(403).json({ message: "Not authorized" }); + } + return res.json(job); +}); + +// Admin: manage users +app.get("/admin/users", authMiddleware, requireAdmin, (req, res) => { + return res.json(data.users.map((u) => normalizeUser(u))); +}); + +app.put("/admin/users/:id", authMiddleware, requireAdmin, async (req, res) => { + const { role } = req.body; + const allowed = ["admin", "user"]; + if (role !== undefined && !allowed.includes(role)) { + return res.status(400).json({ message: "Invalid role" }); + } + const user = data.users.find((u) => u.id === req.params.id); + if (!user) return res.status(404).json({ message: "User not found" }); + if (role !== undefined) user.role = role; + await saveData(data); + return res.json(normalizeUser(user)); +}); + +app.delete("/admin/users/:id", authMiddleware, requireAdmin, async (req, res) => { + const idx = data.users.findIndex((u) => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ message: "User not found" }); + const [removed] = data.users.splice(idx, 1); + // remove sessions + for (const t of Object.keys(data.sessions)) { + if (data.sessions[t].userId === removed.id) delete data.sessions[t]; + } + await saveData(data); + return res.status(204).end(); +}); + app.get("/auth/github", (req, res) => { if (!githubClientId || !githubClientSecret) { return res @@ -303,7 +457,7 @@ app.post("/auth/github/callback", async (req, res) => { email: normalizedEmail, password: "", githubUsername, - githubToken: accessToken, + githubToken: encryptSecret(accessToken), openaiKey: "", totalJobs: 0, successfulPRs: 0, @@ -311,7 +465,7 @@ app.post("/auth/github/callback", async (req, res) => { data.users.push(user); } else { user.githubUsername = githubUsername; - user.githubToken = accessToken; + user.githubToken = encryptSecret(accessToken); user.email = normalizedEmail; } @@ -322,18 +476,45 @@ app.post("/auth/github/callback", async (req, res) => { }; await saveData(data); - return res.json({ - token, - user: normalizeUser(user), - githubUsername, - githubToken: accessToken, - }); + // Set HttpOnly cookie for auth token + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }; + res.cookie && res.cookie("rm_token", token, cookieOpts); + + return res.json({ user: normalizeUser(user), githubUsername }); }); app.get("/health", (_req, res) => { res.send("ok"); }); +// Logout: clear session and cookie +app.post("/auth/logout", (req, res) => { + // find token from cookie or header + const authHeader = req.headers.authorization || ""; + let token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : undefined; + if (!token && req.headers.cookie) { + const cookies = Object.fromEntries(req.headers.cookie.split(/;\s*/).map(c => { + const idx = c.indexOf('='); + return [c.slice(0, idx), decodeURIComponent(c.slice(idx+1))]; + })); + token = cookies['rm_token']; + } + if (token && data.sessions[token]) delete data.sessions[token]; + // clear cookie + if (res.clearCookie) { + res.clearCookie('rm_token', { path: '/' }); + } else { + res.setHeader('Set-Cookie', 'rm_token=; Max-Age=0; Path=/; HttpOnly'); + } + saveData(data).catch(() => {}); + return res.status(204).end(); +}); + app.listen(port, () => { console.log(`Auth server listening on http://localhost:${port}`); console.log( diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 6baed9d..c39c2d6 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -106,45 +106,37 @@ export function AuthProvider({ children }: AuthProviderProps) { const [loading, setLoading] = useState(true); useEffect(() => { - const token = localStorage.getItem(STORAGE_KEYS.token); const cachedUser = readJson(STORAGE_KEYS.user, null); - if (token) { - api.defaults.headers.common["Authorization"] = `Bearer ${token}`; - api.get("/auth/me") - .then((r) => { - const nextUser = stripSensitive(r.data as AuthUser); - if (nextUser) { - writeJson(STORAGE_KEYS.user, nextUser); - setUser(nextUser); - } - }) - .catch((error: unknown) => { - if (shouldUseLocalFallback(error) && cachedUser) { - api.defaults.headers.common["Authorization"] = "Bearer local-session"; - setUser(cachedUser); - return; - } - - localStorage.removeItem(STORAGE_KEYS.token); - localStorage.removeItem(STORAGE_KEYS.user); - delete api.defaults.headers.common["Authorization"]; - }) - .finally(() => setLoading(false)); - } else { - if (cachedUser) { - api.defaults.headers.common["Authorization"] = "Bearer local-session"; - setUser(cachedUser); - } - setLoading(false); - } + + api + .get("/auth/me") + .then((r) => { + const nextUser = stripSensitive(r.data as AuthUser); + if (nextUser) { + writeJson(STORAGE_KEYS.user, nextUser); + setUser(nextUser); + } + }) + .catch((error: unknown) => { + if (shouldUseLocalFallback(error) && cachedUser) { + api.defaults.headers.common["Authorization"] = "Bearer local-session"; + setUser(cachedUser); + return; + } + + // clear cached session info + localStorage.removeItem(STORAGE_KEYS.token); + localStorage.removeItem(STORAGE_KEYS.user); + delete api.defaults.headers.common["Authorization"]; + }) + .finally(() => setLoading(false)); }, []); const login = async (email: string, password: string): Promise => { try { const { data } = await api.post("/auth/login", { email, password }); const nextUser = stripSensitive(data.user as LocalUser | AuthUser); - localStorage.setItem(STORAGE_KEYS.token, data.token); - api.defaults.headers.common["Authorization"] = `Bearer ${data.token}`; + // Server sets HttpOnly cookie for session; persist only the user if (nextUser) { writeJson(STORAGE_KEYS.user, nextUser); setUser(nextUser); @@ -181,8 +173,7 @@ export function AuthProvider({ children }: AuthProviderProps) { try { const { data } = await api.post("/auth/signup", { username, email, password }); const nextUser = stripSensitive(data.user as LocalUser | AuthUser); - localStorage.setItem(STORAGE_KEYS.token, data.token); - api.defaults.headers.common["Authorization"] = `Bearer ${data.token}`; + // Server sets cookie for new session; persist user if (nextUser) { writeJson(STORAGE_KEYS.user, nextUser); setUser(nextUser); @@ -290,8 +281,7 @@ export function AuthProvider({ children }: AuthProviderProps) { } const nextUser = stripSensitive(data.user as LocalUser | AuthUser); - localStorage.setItem(STORAGE_KEYS.token, data.token); - api.defaults.headers.common["Authorization"] = `Bearer ${data.token}`; + // Server sets cookie for the session; persist user if (nextUser) { writeJson(STORAGE_KEYS.user, nextUser); setUser(nextUser); @@ -302,6 +292,8 @@ export function AuthProvider({ children }: AuthProviderProps) { }; const logout = (): void => { + // Ask server to clear session cookie and server-side session + void api.post("/auth/logout").catch(() => {}); localStorage.removeItem(STORAGE_KEYS.token); localStorage.removeItem(STORAGE_KEYS.user); delete api.defaults.headers.common["Authorization"]; diff --git a/src/index.css b/src/index.css index 32556af..f0d15fc 100644 --- a/src/index.css +++ b/src/index.css @@ -597,6 +597,8 @@ label { } .spinner { + display: inline-block; + box-sizing: border-box; width: 20px; height: 20px; border: 2px solid var(--border2); @@ -605,6 +607,13 @@ label { animation: spin 0.75s linear infinite; } +.auth-loading-panel { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; +} + .theme-toggle { display: inline-flex; align-items: center; diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index d6807f7..936ff83 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -19,10 +19,13 @@ function repoName(url: string): string { export default function AnalyticsPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); + const MIN_SPINNER_MS = 1000; // ensure spinner visible for at least this duration useEffect(() => { let mounted = true; async function load() { + setLoading(true); + const start = Date.now(); try { const { data } = await api.get("/jobs"); if (!mounted) return; @@ -30,7 +33,10 @@ export default function AnalyticsPage() { } catch (err) { setJobs([]); } finally { - setLoading(false); + const elapsed = Date.now() - start; + const remaining = Math.max(0, MIN_SPINNER_MS - elapsed); + if (remaining > 0) await new Promise((r) => setTimeout(r, remaining)); + if (mounted) setLoading(false); } } load(); diff --git a/src/pages/DashboardPage.css b/src/pages/DashboardPage.css index fb3d01a..209099e 100644 --- a/src/pages/DashboardPage.css +++ b/src/pages/DashboardPage.css @@ -255,6 +255,8 @@ /* Spinner (reused globally) */ .spinner { + display: inline-block; + box-sizing: border-box; width: 20px; height: 20px; border: 2px solid var(--border2); diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 2a24bdb..27ec7de 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -28,13 +28,20 @@ export default function DashboardPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); + const MIN_SPINNER_MS = 1000; // ensure spinner visible for at least this duration + const load = async (): Promise => { + setLoading(true); + const start = Date.now(); try { const { data } = await api.get("/jobs"); setJobs(data as Job[]); } catch { setJobs([]); } finally { + const elapsed = Date.now() - start; + const remaining = Math.max(0, MIN_SPINNER_MS - elapsed); + if (remaining > 0) await new Promise((r) => setTimeout(r, remaining)); setLoading(false); } }; diff --git a/src/utils/api.ts b/src/utils/api.ts index 14336ed..dfd058d 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -10,6 +10,7 @@ const baseURL = const api = axios.create({ baseURL, timeout: 30000, + withCredentials: true, }); const pendingJobRequests = new Set();