add auth system and split backend into two-image CI/CD build
Some checks failed
Build and Push / build (push) Failing after 17s
Some checks failed
Build and Push / build (push) Failing after 17s
Backend:
- password hashing via hashlib.scrypt
- stateless HMAC-SHA256 tokens (7-day expiry)
- POST /api/auth/login, /api/auth/register (admin), /api/auth/reset-password
- admin user created from NAT20_ADMIN_USERNAME/PASSWORD on first startup
- users table, campaign_shares table, created_by on campaigns
- require_user dependency on all routes except auth
- campaign sharing: GET/POST/DELETE /api/campaigns/{id}/shares
Frontend:
- AuthContext: user/token state, login/logout, global fetch Auth header
- Login page, Users page (admin user management)
- route protection, sidebar user info/sign out
Docker/CI:
- split backend/Dockerfile into thin app-only image
- backend/Dockerfile.deps builds the heavy WhisperX/PyTorch base
- CI builds deps only when requirements.txt changes
- docker compose pull now fetches ~100KB app layer instead of 3.5GB
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import Login from './pages/Login';
|
||||
import Setup from './pages/Setup';
|
||||
import Sessions from './pages/Sessions';
|
||||
import SessionDetail from './pages/SessionDetail';
|
||||
@@ -9,10 +11,12 @@ import Files from './pages/Files';
|
||||
import Settings from './pages/Settings';
|
||||
import Diagnostics from './pages/Diagnostics';
|
||||
import Campaigns from './pages/Campaigns';
|
||||
import Users from './pages/Users';
|
||||
import Layout from './components/Layout';
|
||||
import { api } from './api';
|
||||
|
||||
function App() {
|
||||
function AppInner() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isConfigured, setIsConfigured] = useState(false);
|
||||
|
||||
@@ -29,19 +33,26 @@ function App() {
|
||||
checkSettings();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>;
|
||||
if (authLoading || loading) {
|
||||
return <div className="min-h-screen bg-deep-900 text-white flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* If not configured, force them to the Setup wizard */}
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route
|
||||
path="/setup"
|
||||
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
|
||||
/>
|
||||
|
||||
{/* If configured, serve the main sessions workspace */}
|
||||
{isConfigured ? (
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Sessions />} />
|
||||
@@ -50,6 +61,7 @@ function App() {
|
||||
<Route path="/sessions/:id/speakers" element={<Speakers />} />
|
||||
<Route path="/sessions/:id/notes" element={<Notes />} />
|
||||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/diagnostics" element={<Diagnostics />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
@@ -61,4 +73,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppInner />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useCampaign } from "../contexts/CampaignContext";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Transcriptions" },
|
||||
{ to: "/files", label: "File Browser" },
|
||||
{ to: "/users", label: "Users" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
{ to: "/diagnostics", label: "System Check" },
|
||||
];
|
||||
@@ -12,6 +14,7 @@ const NAV = [
|
||||
export default function Sidebar() {
|
||||
const { pathname } = useLocation();
|
||||
const { currentCampaign, setCampaign, campaigns } = useCampaign();
|
||||
const { user, logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const nav = useNavigate();
|
||||
@@ -90,6 +93,15 @@ export default function Sidebar() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/5 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-ink/40 truncate">{user?.username}</span>
|
||||
<button onClick={logout} className="text-xs text-ink/30 hover:text-ember-400 transition-colors">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
94
frontend/src/contexts/AuthContext.tsx
Normal file
94
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString();
|
||||
if (url.startsWith("/api/") && !url.startsWith("/api/auth/login")) {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
init = init || {};
|
||||
init.headers = { ...(init.headers as Record<string, string> || {}), Authorization: `Bearer ${token}` };
|
||||
}
|
||||
}
|
||||
return origFetch.call(window, input, init).then((res) => {
|
||||
if (res.status === 401 && url.startsWith("/api/") && !url.startsWith("/api/auth/login") && !url.startsWith("/api/auth/me")) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>(null!);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoading(false); return; }
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
.then((u) => {
|
||||
u.is_admin = !!u.is_admin;
|
||||
setUser(u);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [token]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(err.detail || "Login failed");
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem("token", data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
50
frontend/src/pages/Login.tsx
Normal file
50
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-deep-900 px-4">
|
||||
<div className="card w-full max-w-sm p-8">
|
||||
<h1 className="font-serif text-3xl text-brass-400 mb-1">Nat20 Notes</h1>
|
||||
<p className="text-sm text-brass-600 mb-6">Sign in to continue</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <p className="text-ember-400 text-sm">{error}</p>}
|
||||
<div>
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/Users.tsx
Normal file
115
frontend/src/pages/Users.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { token, user } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/auth/users", { headers });
|
||||
if (res.ok) setUsers(await res.json());
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const createUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username: newUsername, password: newPassword }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
setError(err.detail || "Failed");
|
||||
return;
|
||||
}
|
||||
setSuccess(`User "${newUsername}" created`);
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
load();
|
||||
};
|
||||
|
||||
const resetPassword = async (userId: string, username: string) => {
|
||||
const pw = prompt(`New password for "${username}":`);
|
||||
if (!pw || pw.length < 4) { setError("Password must be at least 4 characters"); return; }
|
||||
setError("");
|
||||
setSuccess("");
|
||||
const res = await fetch("/api/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ user_id: userId, new_password: pw }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Failed" }));
|
||||
setError(err.detail || "Failed");
|
||||
return;
|
||||
}
|
||||
setSuccess(`Password reset for "${username}"`);
|
||||
};
|
||||
|
||||
if (!user?.is_admin) return <p className="text-ember-400 p-8">Admin access required.</p>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-lg">
|
||||
<h2 className="font-serif text-2xl text-brass-400 mb-4">User Management</h2>
|
||||
|
||||
{error && <p className="text-ember-400 text-sm mb-3">{error}</p>}
|
||||
{success && <p className="text-green-400 text-sm mb-3">{success}</p>}
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<h3 className="eyebrow mb-3">Create User</h3>
|
||||
<form onSubmit={createUser} className="space-y-3">
|
||||
<input className="input w-full" placeholder="Username" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||
<input className="input w-full" type="password" placeholder="Password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<button type="submit" className="btn-primary">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h3 className="eyebrow mb-3">Users</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-brass-600 border-b border-deep-600">
|
||||
<th className="text-left py-2">Username</th>
|
||||
<th className="text-left py-2">Admin</th>
|
||||
<th className="text-right py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-deep-700">
|
||||
<td className="py-2">{u.username}</td>
|
||||
<td className="py-2">{u.is_admin ? "Yes" : ""}</td>
|
||||
<td className="py-2 text-right">
|
||||
{!u.is_admin && (
|
||||
<button
|
||||
className="text-brass-400 hover:text-brass-300 text-xs"
|
||||
onClick={() => resetPassword(u.id, u.username)}
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user