add auth system and split backend into two-image CI/CD build
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:
KansaiGaijin
2026-07-30 12:05:56 +12:00
parent 990276366d
commit 5081ba00f6
16 changed files with 800 additions and 114 deletions

View File

@@ -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;

View File

@@ -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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}