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

@@ -2,7 +2,9 @@
## Commands ## Commands
- `docker compose up -d --build` — build & launch both services (backend :8000, frontend :8020) - `make deps` — one-time build of the heavy WhisperX/PyTorch layer (5-10 min)
- `make build` — fast app-only rebuild (seconds)
- `docker compose pull && docker compose up -d` — pull & run pre-built images from registry
- `cd frontend && npm run dev` — Vite dev server (proxies `/api``localhost:8000`) - `cd frontend && npm run dev` — Vite dev server (proxies `/api``localhost:8000`)
- **No tests, no lint/typecheck scripts exist.** Don't look for them. - **No tests, no lint/typecheck scripts exist.** Don't look for them.
@@ -23,10 +25,34 @@ When adding a setting key, add it to `config.py:CAMPAIGN_SETTINGS`, the frontend
## Docker / framework quirks ## Docker / framework quirks
- Torch pinned to `2.3.1` with `cu121` wheels (`--extra-index-url https://download.pytorch.org/whl/cu121`) — cuDNN ABI compat with `ctranslate2`. Don't bump unpinned. - Torch pinned to `2.3.1` with `cu121` wheels (`--extra-index-url https://download.pytorch.org/whl/cu121`) — cuDNN ABI compat with `ctranslate2`. Don't bump unpinned.
- Backend Dockerfile is two-stage (builder + runtime). ffmpeg required in runtime for audio extraction. - Backend has two Dockerfiles:
- `Dockerfile` — thin, app-only. Starts `FROM nat20-whisperx-base:latest`. Used by CI for fast rebuilds and by `make build`.
- `Dockerfile.deps` — full WhisperX base layer. Build once per `requirements.txt` change. Used by CI and `make deps`.
- `Makefile``make deps` builds the heavy layer; `make build` does fast app-only rebuild.
- **CI/CD pattern**: Gitea runner builds & pushes `nat20-whisperx-base:latest` only when `requirements.txt` changes, then builds & pushes `backend:latest` (thin) on every push. Your `docker compose pull` only downloads new app layers (~100 KB) — the 3 GB WhisperX layer stays cached.
- ffmpeg required in runtime for audio extraction.
- nginx `client_max_body_size` 500M, proxy timeouts 3600s. Files >15MB auto-chunked by frontend `createSession()`. - nginx `client_max_body_size` 500M, proxy timeouts 3600s. Files >15MB auto-chunked by frontend `createSession()`.
- Frontend changes in Compose mode require a rebuild (`docker compose up -d --build`). Vite dev server is for local-only dev. - Frontend changes in Compose mode require a rebuild (`docker compose up -d --build`). Vite dev server is for local-only dev.
### Optimised rebuild workflow
```bash
# One-time: build the heavy WhisperX/PyTorch layer
make deps
# Daily: rebuild only app code (seconds, not minutes)
make build
# Or with docker compose if you've done make deps first:
docker compose up -d --build
```
## Auth
- First user is admin, created from `NAT20_ADMIN_USERNAME` / `NAT20_ADMIN_PASSWORD` env vars on first startup.
- Admin can create users via the Users page. Users can share campaigns with other registered users.
- JWT tokens (HMACSHA256, 7-day expiry) stored in localStorage. Global fetch interceptor adds `Authorization: Bearer` to all `/api/` calls.
## Tailwind theme ## Tailwind theme
Custom colors (`deep`, `panel`, `brass`, `ember`, etc.) and fonts (`Fraunces`, `Inter`, `IBM Plex Mono`) in `tailwind.config.js`. Reusable component classes in `frontend/src/styles.css` (`.card`, `.btn-primary`, `.btn-secondary`, `.input`, `.eyebrow`) — use these over raw Tailwind utilities. Custom colors (`deep`, `panel`, `brass`, `ember`, etc.) and fonts (`Fraunces`, `Inter`, `IBM Plex Mono`) in `tailwind.config.js`. Reusable component classes in `frontend/src/styles.css` (`.card`, `.btn-primary`, `.btn-secondary`, `.input`, `.eyebrow`) — use these over raw Tailwind utilities.

View File

@@ -1,55 +1,19 @@
# ========================================== # ==========================================
# STAGE 1 — builder: compile Python deps # App-only image — fast CI/CD rebuilds.
#
# Deps are pre-built and pushed as a separate
# image (nat20-whisperx-base). CI builds and
# pushes that image only when requirements.txt
# changes, so this build typically only copies
# app code — no pip install, no heavy layers.
#
# Local dev — build the base once:
# docker build -f Dockerfile.deps -t nat20-whisperx-base .
# Then build this image as needed:
# docker build -t nat20-notes-backend .
# ========================================== # ==========================================
# Changes only when requirements.txt is modified.
FROM python:3.11-slim AS builder
WORKDIR /build FROM nat20-whisperx-base:latest
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
patchelf \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_NO_CACHE_DIR=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt .
RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt
# Silence the "Lightning auto-upgraded checkpoint" warning on every diarization run
RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true
RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so*
RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \
&& find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \
&& find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \;
# ==========================================
# STAGE 2 — whisperx-base: deps only, no app
# ==========================================
# Targeted by docker-compose for dev caching.
# If you only need this layer: docker build --target=whisperx-base -t nat20-whisperx-base .
FROM python:3.11-slim AS whisperx-base
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
# ==========================================
# STAGE 3 — runtime: full release image
# ==========================================
# Build for production: docker build --target=runtime -t nat20-notes-backend .
# Layers app code on top of whisperx-base in a single Dockerfile pass.
FROM whisperx-base AS runtime
WORKDIR /app WORKDIR /app

43
backend/Dockerfile.deps Normal file
View File

@@ -0,0 +1,43 @@
# ==========================================
# Build the heavy WhisperX dependency layer.
# Build once (or when requirements.txt changes):
# docker build -f Dockerfile.deps -t nat20-whisperx-base .
# ==========================================
# Stage 1 — builder: compile Python deps
FROM python:3.11-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
patchelf \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_NO_CACHE_DIR=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt .
RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt
RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true
RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so*
RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \
&& find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \
&& find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \;
# Stage 2 — runtime base: system deps + Python packages, no app code
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1

36
backend/Makefile Normal file
View File

@@ -0,0 +1,36 @@
# ────────────────────────────────────────────────────
# Build helpers for the two-image CI/CD setup.
#
# Deps (WhisperX, PyTorch, etc.) live in a separate
# base image that seldom changes. CI builds deps
# only when requirements.txt changes, then builds
# the thin app image on every push — no pip install
# during the common case, so pulls are tiny.
#
# Local dev — same flow:
# make deps # one-time, 5-10 min
# make build # fast, every time
# ────────────────────────────────────────────────────
DOCKER := docker
COMPOSE := docker compose
DEPS_IMG := nat20-whisperx-base
DEPS_FILE := Dockerfile.deps
.PHONY: deps build run clean
## deps — Build the heavy deps layer (only when requirements.txt changes)
deps:
$(DOCKER) build -f $(DEPS_FILE) -t $(DEPS_IMG) .
## build — Build the app image (fast — no pip)
build:
$(DOCKER) build -t nat20-notes-backend .
## run — Start everything with docker compose
run:
$(COMPOSE) up -d --build
## clean — Remove dangling images
clean:
$(DOCKER) image prune -f

100
backend/app/auth.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Authentication module — zero-dependency (stdlib only).
Password hashing via hashlib.scrypt, stateless tokens via HMAC-SHA256.
"""
import base64
import hashlib
import hmac
import json
import os
import time
from pathlib import Path
from typing import Optional
from . import database as db
from .config import ADMIN_PASSWORD, ADMIN_USERNAME, JWT_SECRET
JWT_ALGORITHM = "HS256"
JWT_EXPIRY = 86400 * 7 # 7 days
def hash_password(password: str) -> str:
salt = os.urandom(16)
key = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32)
return base64.b64encode(salt + key).decode()
def verify_password(password: str, stored: str) -> bool:
try:
data = base64.b64decode(stored)
salt, key = data[:16], data[16:]
key2 = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32)
return hmac.compare_digest(key, key2)
except Exception:
return False
def create_token(user_id: str, username: str, is_admin: bool) -> str:
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"sub": user_id,
"username": username,
"admin": is_admin,
"iat": time.time(),
"exp": time.time() + JWT_EXPIRY,
}
h = base64.urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode()).rstrip(b"=").decode()
p = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).rstrip(b"=").decode()
sig = hmac.new(JWT_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
s = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
return f"{h}.{p}.{s}"
def decode_token(token: str) -> Optional[dict]:
try:
parts = token.split(".")
if len(parts) != 3:
return None
h, p, s = parts
sig = base64.urlsafe_b64decode(s + "==")
expected = hmac.new(JWT_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
payload = json.loads(base64.urlsafe_b64decode(p + "=="))
if payload.get("exp", 0) < time.time():
return None
return payload
except Exception:
return None
def authenticate(username: str, password: str) -> Optional[dict]:
user = db.get_user_by_username(username)
if not user or not verify_password(password, user["password_hash"]):
return None
token = create_token(user["id"], user["username"], bool(user["is_admin"]))
return {
"token": token,
"user": {"id": user["id"], "username": user["username"], "is_admin": bool(user["is_admin"])},
}
def get_user_from_token(token: str) -> Optional[dict]:
payload = decode_token(token)
if not payload:
return None
user = db.get_user(payload["sub"])
if not user:
return None
return {"id": user["id"], "username": user["username"], "is_admin": bool(user["is_admin"])}
def ensure_admin_exists():
"""Called on startup — creates the admin user if no users exist."""
conn = db.get_conn()
row = conn.execute("SELECT COUNT(*) as cnt FROM users").fetchone()
if row["cnt"] > 0:
return
pw = ADMIN_PASSWORD or os.urandom(8).hex()
db.create_user(ADMIN_USERNAME, hash_password(pw), is_admin=True)
print(f"[auth] Created admin user '{ADMIN_USERNAME}' (password: {pw})")

View File

@@ -28,6 +28,11 @@ def campaign_transcript_dir(campaign_id: str) -> Path:
def campaign_notes_dir(campaign_id: str) -> Path: def campaign_notes_dir(campaign_id: str) -> Path:
return campaign_dir(campaign_id) / "notes" return campaign_dir(campaign_id) / "notes"
# Auth config
JWT_SECRET = os.environ.get("NAT20_JWT_SECRET", os.urandom(32).hex())
ADMIN_USERNAME = os.environ.get("NAT20_ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.environ.get("NAT20_ADMIN_PASSWORD", "admin")
for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR): for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR):
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)

View File

@@ -87,14 +87,36 @@ def init_db():
updated_at REAL NOT NULL updated_at REAL NOT NULL
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS campaign_shares (
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'editor',
PRIMARY KEY (campaign_id, user_id)
)
""")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS campaigns ( CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL created_at REAL NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL
) )
""") """)
try:
conn.execute("ALTER TABLE campaigns ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL")
except Exception:
pass
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS campaign_settings ( CREATE TABLE IF NOT EXISTS campaign_settings (
campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
@@ -110,7 +132,7 @@ def init_db():
default = conn.execute("SELECT id FROM campaigns WHERE id = 'default'").fetchone() default = conn.execute("SELECT id FROM campaigns WHERE id = 'default'").fetchone()
if not default: if not default:
conn.execute( conn.execute(
"INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, '', ?)", "INSERT INTO campaigns (id, name, description, created_at, created_by) VALUES (?, ?, '', ?, NULL)",
("default", "Default Campaign", now()), ("default", "Default Campaign", now()),
) )
conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL") conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL")
@@ -207,12 +229,12 @@ def get_job(job_id: str) -> dict | None:
row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return dict(row) if row else None return dict(row) if row else None
def create_campaign(name: str, description: str = "") -> dict: def create_campaign(name: str, description: str = "", created_by: str | None = None) -> dict:
campaign_id = new_id() campaign_id = new_id()
with tx() as conn: with tx() as conn:
conn.execute( conn.execute(
"INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)", "INSERT INTO campaigns (id, name, description, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
(campaign_id, name, description, now()), (campaign_id, name, description, now(), created_by),
) )
# Inherit CAMPAIGN_SETTINGS from the "default" campaign as a starting point # Inherit CAMPAIGN_SETTINGS from the "default" campaign as a starting point
parent = conn.execute( parent = conn.execute(
@@ -255,6 +277,89 @@ def update_campaign(campaign_id: str, name: str | None = None, description: str
conn.execute(f"UPDATE campaigns SET {cols} WHERE id = ?", (*fields.values(), campaign_id)) conn.execute(f"UPDATE campaigns SET {cols} WHERE id = ?", (*fields.values(), campaign_id))
return get_campaign(campaign_id) return get_campaign(campaign_id)
# ── Users ──────────────────────────────────────────────────────────────────
def get_user(user_id: str) -> dict | None:
row = get_conn().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
def get_user_by_username(username: str) -> dict | None:
row = get_conn().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
return dict(row) if row else None
def create_user(username: str, password_hash: str, is_admin: bool = False) -> dict:
uid = new_id()
with tx() as conn:
conn.execute(
"INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?, ?)",
(uid, username, password_hash, 1 if is_admin else 0, now()),
)
return get_user(uid)
def update_user_password(user_id: str, password_hash: str):
with tx() as conn:
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
def list_users() -> list[dict]:
rows = get_conn().execute(
"SELECT id, username, is_admin, created_at FROM users ORDER BY created_at"
).fetchall()
return [dict(r) for r in rows]
# ── Campaign sharing ──────────────────────────────────────────────────────
def share_campaign(campaign_id: str, user_id: str, role: str = "editor"):
with tx() as conn:
conn.execute(
"INSERT OR REPLACE INTO campaign_shares (campaign_id, user_id, role) VALUES (?, ?, ?)",
(campaign_id, user_id, role),
)
def unshare_campaign(campaign_id: str, user_id: str):
with tx() as conn:
conn.execute(
"DELETE FROM campaign_shares WHERE campaign_id = ? AND user_id = ?",
(campaign_id, user_id),
)
def get_campaign_shares(campaign_id: str) -> list[dict]:
rows = get_conn().execute("""
SELECT u.id, u.username, cs.role
FROM campaign_shares cs
JOIN users u ON u.id = cs.user_id
WHERE cs.campaign_id = ?
""", (campaign_id,)).fetchall()
return [dict(r) for r in rows]
def get_user_campaigns(user_id: str) -> list[dict]:
rows = get_conn().execute("""
SELECT c.*, cs.role as access_role,
(SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count
FROM campaigns c
LEFT JOIN campaign_shares cs ON cs.campaign_id = c.id
WHERE c.created_by = ? OR cs.user_id = ?
ORDER BY c.created_at DESC
""", (user_id, user_id)).fetchall()
return [dict(r) for r in rows]
def can_access_campaign(user_id: str, campaign_id: str) -> bool:
row = get_conn().execute("""
SELECT 1 FROM campaigns WHERE id = ? AND created_by = ?
UNION
SELECT 1 FROM campaign_shares WHERE campaign_id = ? AND user_id = ?
""", (campaign_id, user_id, campaign_id, user_id)).fetchone()
return row is not None
def delete_campaign(campaign_id: str) -> bool: def delete_campaign(campaign_id: str) -> bool:
with tx() as conn: with tx() as conn:
conn.execute("UPDATE sessions SET campaign_id = NULL WHERE campaign_id = ?", (campaign_id,)) conn.execute("UPDATE sessions SET campaign_id = NULL WHERE campaign_id = ?", (campaign_id,))

View File

@@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
@@ -8,6 +8,9 @@ from fastapi import HTTPException
from . import database as db, config from . import database as db, config
from .logging_config import setup_logging, get_logger from .logging_config import setup_logging, get_logger
from .errors import PipelineError from .errors import PipelineError
from .auth import ensure_admin_exists
from .routers.auth import require_user
from .routers import auth as auth_router
from .routers import settings as settings_router from .routers import settings as settings_router
from .routers import sessions as sessions_router from .routers import sessions as sessions_router
from .routers import speakers as speakers_router from .routers import speakers as speakers_router
@@ -46,18 +49,20 @@ async def unhandled_error_handler(request: Request, exc: Exception):
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
db.init_db() db.init_db()
ensure_admin_exists()
log.info("Database initialized at %s", config.DB_PATH) log.info("Database initialized at %s", config.DB_PATH)
app.include_router(settings_router.router) app.include_router(auth_router.router)
app.include_router(sessions_router.router) app.include_router(settings_router.router, dependencies=[Depends(require_user)])
app.include_router(speakers_router.router) app.include_router(sessions_router.router, dependencies=[Depends(require_user)])
app.include_router(notes_router.router) app.include_router(speakers_router.router, dependencies=[Depends(require_user)])
app.include_router(jobs_router.router) app.include_router(notes_router.router, dependencies=[Depends(require_user)])
app.include_router(models_router.router) app.include_router(jobs_router.router, dependencies=[Depends(require_user)])
app.include_router(diagnostics_router.router) app.include_router(models_router.router, dependencies=[Depends(require_user)])
app.include_router(files_router.router) app.include_router(diagnostics_router.router, dependencies=[Depends(require_user)])
app.include_router(campaigns_router.router) app.include_router(files_router.router, dependencies=[Depends(require_user)])
app.include_router(campaigns_router.router, dependencies=[Depends(require_user)])
MEDIA_TYPES = { MEDIA_TYPES = {
@@ -77,7 +82,7 @@ AUDIO_EXTS = frozenset(MEDIA_TYPES.keys())
@app.get("/api/sessions/{session_id}/audio") @app.get("/api/sessions/{session_id}/audio")
def get_audio(session_id: str): def get_audio(session_id: str, user: dict = Depends(require_user)):
row = db.get_conn().execute( row = db.get_conn().execute(
"SELECT video_path, audio_path FROM sessions WHERE id = ?", (session_id,) "SELECT video_path, audio_path FROM sessions WHERE id = ?", (session_id,)
).fetchone() ).fetchone()

View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException, Header
from typing import Optional
from .. import database as db
from ..auth import authenticate, get_user_from_token, hash_password, ensure_admin_exists
router = APIRouter(prefix="/api/auth", tags=["auth"])
def require_user(authorization: Optional[str] = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Not authenticated")
user = get_user_from_token(authorization[7:])
if not user:
raise HTTPException(401, "Invalid or expired token")
return user
def require_admin(user: dict = Depends(require_user)):
if not user["is_admin"]:
raise HTTPException(403, "Admin access required")
return user
@router.post("/login")
def login(body: dict):
username = body.get("username", "").strip()
password = body.get("password", "")
if not username or not password:
raise HTTPException(400, "Username and password are required")
result = authenticate(username, password)
if not result:
raise HTTPException(401, "Invalid username or password")
return result
@router.post("/register")
def register(body: dict, admin: dict = Depends(require_admin)):
username = body.get("username", "").strip()
password = body.get("password", "")
if not username or not password:
raise HTTPException(400, "Username and password are required")
if len(password) < 4:
raise HTTPException(400, "Password must be at least 4 characters")
existing = db.get_user_by_username(username)
if existing:
raise HTTPException(409, "Username already exists")
user = db.create_user(username, hash_password(password))
return {"id": user["id"], "username": user["username"]}
@router.post("/reset-password")
def reset_password(body: dict, admin: dict = Depends(require_admin)):
user_id = body.get("user_id", "").strip()
new_password = body.get("new_password", "")
if not user_id or not new_password:
raise HTTPException(400, "user_id and new_password are required")
if len(new_password) < 4:
raise HTTPException(400, "Password must be at least 4 characters")
user = db.get_user(user_id)
if not user:
raise HTTPException(404, "User not found")
db.update_user_password(user_id, hash_password(new_password))
return {"ok": True}
@router.get("/me")
def me(user: dict = Depends(require_user)):
user["is_admin"] = bool(user["is_admin"])
return user
@router.get("/users")
def list_users(admin: dict = Depends(require_admin)):
return db.list_users()

View File

@@ -1,36 +1,36 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from .. import database as db from .. import database as db
from ..config import CAMPAIGN_SETTINGS, merge_campaign_settings_with_env from ..config import CAMPAIGN_SETTINGS, merge_campaign_settings_with_env
from .auth import require_user, require_admin
router = APIRouter(prefix="/api/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/api/campaigns", tags=["campaigns"])
@router.get("") @router.get("")
def list_campaigns(): def list_campaigns(user: dict = Depends(require_user)):
return db.list_campaigns() return db.get_user_campaigns(user["id"])
@router.post("") @router.post("")
def create_campaign(body: dict): def create_campaign(body: dict, user: dict = Depends(require_user)):
name = body.get("name", "").strip() name = body.get("name", "").strip()
if not name: if not name:
raise HTTPException(400, "Name is required") raise HTTPException(400, "Name is required")
desc = body.get("description", "").strip() desc = body.get("description", "").strip()
return db.create_campaign(name, desc) return db.create_campaign(name, desc, user["id"])
@router.get("/{campaign_id}") @router.get("/{campaign_id}")
def get_campaign(campaign_id: str): def get_campaign(campaign_id: str, user: dict = Depends(require_user)):
campaign = db.get_campaign(campaign_id) if not db.can_access_campaign(user["id"], campaign_id):
if not campaign:
raise HTTPException(404, "Campaign not found") raise HTTPException(404, "Campaign not found")
return campaign return db.get_campaign(campaign_id)
@router.put("/{campaign_id}") @router.put("/{campaign_id}")
def update_campaign(campaign_id: str, body: dict): def update_campaign(campaign_id: str, body: dict, user: dict = Depends(require_user)):
if not db.get_campaign(campaign_id): if not db.can_access_campaign(user["id"], campaign_id):
raise HTTPException(404, "Campaign not found") raise HTTPException(404, "Campaign not found")
updated = db.update_campaign( updated = db.update_campaign(
campaign_id, campaign_id,
@@ -41,26 +41,80 @@ def update_campaign(campaign_id: str, body: dict):
@router.delete("/{campaign_id}") @router.delete("/{campaign_id}")
def delete_campaign(campaign_id: str): def delete_campaign(campaign_id: str, user: dict = Depends(require_user)):
if not db.get_campaign(campaign_id): campaign = db.get_campaign(campaign_id)
if not campaign:
raise HTTPException(404, "Campaign not found") raise HTTPException(404, "Campaign not found")
if campaign.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the campaign owner can delete it")
db.delete_campaign(campaign_id) db.delete_campaign(campaign_id)
return {"ok": True} return {"ok": True}
# ── Campaign settings ────────────────────────────────────────────────────
@router.get("/{campaign_id}/settings") @router.get("/{campaign_id}/settings")
def get_campaign_settings(campaign_id: str): def get_campaign_settings(campaign_id: str, user: dict = Depends(require_user)):
if not db.get_campaign(campaign_id): if not db.can_access_campaign(user["id"], campaign_id):
raise HTTPException(404, "Campaign not found") raise HTTPException(404, "Campaign not found")
raw = db.get_campaign_settings(campaign_id) raw = db.get_campaign_settings(campaign_id)
return merge_campaign_settings_with_env(raw) return merge_campaign_settings_with_env(raw)
@router.post("/{campaign_id}/settings") @router.post("/{campaign_id}/settings")
def update_campaign_settings(campaign_id: str, body: dict): def update_campaign_settings(campaign_id: str, body: dict, user: dict = Depends(require_user)):
if not db.get_campaign(campaign_id): if not db.can_access_campaign(user["id"], campaign_id):
raise HTTPException(404, "Campaign not found") raise HTTPException(404, "Campaign not found")
clean = {k: str(v) for k, v in body.items() if k in CAMPAIGN_SETTINGS} clean = {k: str(v) for k, v in body.items() if k in CAMPAIGN_SETTINGS}
db.update_campaign_settings(campaign_id, clean) db.update_campaign_settings(campaign_id, clean)
raw = db.get_campaign_settings(campaign_id) raw = db.get_campaign_settings(campaign_id)
return merge_campaign_settings_with_env(raw) return merge_campaign_settings_with_env(raw)
# ── Sharing ───────────────────────────────────────────────────────────────
@router.get("/{campaign_id}/shares")
def list_shares(campaign_id: str, user: dict = Depends(require_user)):
campaign = db.get_campaign(campaign_id)
if not campaign:
raise HTTPException(404, "Campaign not found")
if campaign.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the campaign owner can manage sharing")
return db.get_campaign_shares(campaign_id)
@router.post("/{campaign_id}/shares")
def add_share(campaign_id: str, body: dict, user: dict = Depends(require_user)):
campaign = db.get_campaign(campaign_id)
if not campaign:
raise HTTPException(404, "Campaign not found")
if campaign.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the campaign owner can manage sharing")
target_username = body.get("username", "").strip()
if not target_username:
raise HTTPException(400, "username is required")
target = db.get_user_by_username(target_username)
if not target:
raise HTTPException(404, "User not found")
if target["id"] == campaign["created_by"]:
raise HTTPException(400, "Cannot share campaign with its owner")
role = body.get("role", "editor")
db.share_campaign(campaign_id, target["id"], role)
return {"ok": True}
@router.delete("/{campaign_id}/shares")
def remove_share(campaign_id: str, body: dict, user: dict = Depends(require_user)):
campaign = db.get_campaign(campaign_id)
if not campaign:
raise HTTPException(404, "Campaign not found")
if campaign.get("created_by") != user["id"] and not user.get("is_admin"):
raise HTTPException(403, "Only the campaign owner can manage sharing")
target_username = body.get("username", "").strip()
if not target_username:
raise HTTPException(400, "username is required")
target = db.get_user_by_username(target_username)
if not target:
raise HTTPException(404, "User not found")
db.unshare_campaign(campaign_id, target["id"])
return {"ok": True}

View File

@@ -6,24 +6,22 @@ services:
backend: backend:
# ── Pre-built image (pull from registry) ────────────────── # ── Pre-built image (pull from registry) ──────────────────
image: gitea.kansaigaijin.com/Jamie/Nat20-Notes/backend:latest image: gitea.kansaigaijin.com/Jamie/Nat20-Notes/backend:latest
# ── Or build locally (uncomment below) ──────────────────── # ── Or build locally after make deps (uncomment below) ────
# build: # build: ./backend
# context: ./backend
# target: runtime
restart: unless-stopped restart: unless-stopped
environment: environment:
# ── Auth ────────────────────────────────────────────────
NAT20_ADMIN_USERNAME: "admin"
NAT20_ADMIN_PASSWORD: "admin"
# ── Setup wizard ────────────────────────────────────── # ── Setup wizard ──────────────────────────────────────
# "false" (or omit) to show the wizard on first run. # "false" (or omit) to show the wizard on first run.
# Set to "true" after onboarding completes. # Set to "true" after onboarding completes.
NAT20_ONBOARDING_COMPLETED: "false" NAT20_ONBOARDING_COMPLETED: "false"
# ── Transcription ───────────────────────────────────── # ── Transcription ─────────────────────────────────────
# Model size: tiny | base | small | medium | large-v3
NAT20_WHISPER_MODEL: medium NAT20_WHISPER_MODEL: medium
# Compute precision: int8 (fastest/least VRAM)
# | float16 (more accurate)
# | float32 (full precision, slowest)
NAT20_WHISPER_COMPUTE_TYPE: int8 NAT20_WHISPER_COMPUTE_TYPE: int8
# Required for speaker diarization (accept HF gated-model terms first) # Required for speaker diarization (accept HF gated-model terms first)
@@ -40,30 +38,14 @@ services:
# NAT20_API_MODEL: gpt-4o-mini # NAT20_API_MODEL: gpt-4o-mini
# ── Summarization ───────────────────────────────────── # ── Summarization ─────────────────────────────────────
# Target words per chunk. Long transcripts are split into
# chunks, each summarized separately. Lower = more LLM
# calls but finer granularity. Higher = more context per
# chunk but may exceed the model's context window.
# Default 2500 works for most models (8K128K context).
# NAT20_CHUNK_WORD_TARGET: "2500" # NAT20_CHUNK_WORD_TARGET: "2500"
# Campaign context injected into every summarization prompt.
# Inline string or path to a file inside the container.
# NAT20_WORLD_CONTEXT: "" # NAT20_WORLD_CONTEXT: ""
# NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt # NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt
# Player recap format: story | diary | bullets | custom
# NAT20_PLAYER_RECAP_STYLE: story # NAT20_PLAYER_RECAP_STYLE: story
# NAT20_PLAYER_RECAP_CUSTOM_PROMPT: "" # NAT20_PLAYER_RECAP_CUSTOM_PROMPT: ""
volumes: volumes:
# ── Data persistence ──────────────────────────────────
# Option A: Named volume (auto-managed, no host path needed)
- app_data:/data - app_data:/data
# Option B: Host bind mount (replace with your path)
# - /mnt/media/dnd-sessions:/data
# HuggingFace + Torch model caches (avoid re-downloading)
- hf_cache:/root/.cache/huggingface - hf_cache:/root/.cache/huggingface
- torch_cache:/root/.cache/torch - torch_cache:/root/.cache/torch

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; 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 Setup from './pages/Setup';
import Sessions from './pages/Sessions'; import Sessions from './pages/Sessions';
import SessionDetail from './pages/SessionDetail'; import SessionDetail from './pages/SessionDetail';
@@ -9,10 +11,12 @@ import Files from './pages/Files';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import Diagnostics from './pages/Diagnostics'; import Diagnostics from './pages/Diagnostics';
import Campaigns from './pages/Campaigns'; import Campaigns from './pages/Campaigns';
import Users from './pages/Users';
import Layout from './components/Layout'; import Layout from './components/Layout';
import { api } from './api'; import { api } from './api';
function App() { function AppInner() {
const { user, loading: authLoading } = useAuth();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isConfigured, setIsConfigured] = useState(false); const [isConfigured, setIsConfigured] = useState(false);
@@ -29,19 +33,26 @@ function App() {
checkSettings(); checkSettings();
}, []); }, []);
if (loading) { if (authLoading || loading) {
return <div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">Loading...</div>; 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 ( return (
<Routes> <Routes>
{/* If not configured, force them to the Setup wizard */} <Route path="/login" element={<Navigate to="/" />} />
<Route <Route
path="/setup" path="/setup"
element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />} element={!isConfigured ? <Setup onComplete={checkSettings} /> : <Navigate to="/" />}
/> />
{/* If configured, serve the main sessions workspace */}
{isConfigured ? ( {isConfigured ? (
<Route element={<Layout />}> <Route element={<Layout />}>
<Route path="/" element={<Sessions />} /> <Route path="/" element={<Sessions />} />
@@ -50,6 +61,7 @@ function App() {
<Route path="/sessions/:id/speakers" element={<Speakers />} /> <Route path="/sessions/:id/speakers" element={<Speakers />} />
<Route path="/sessions/:id/notes" element={<Notes />} /> <Route path="/sessions/:id/notes" element={<Notes />} />
<Route path="/files" element={<Files />} /> <Route path="/files" element={<Files />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/diagnostics" element={<Diagnostics />} /> <Route path="/diagnostics" element={<Diagnostics />} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
@@ -61,4 +73,12 @@ function App() {
); );
} }
function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
);
}
export default App; export default App;

View File

@@ -1,10 +1,12 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { useCampaign } from "../contexts/CampaignContext"; import { useCampaign } from "../contexts/CampaignContext";
import { useAuth } from "../contexts/AuthContext";
const NAV = [ const NAV = [
{ to: "/", label: "Transcriptions" }, { to: "/", label: "Transcriptions" },
{ to: "/files", label: "File Browser" }, { to: "/files", label: "File Browser" },
{ to: "/users", label: "Users" },
{ to: "/settings", label: "Settings" }, { to: "/settings", label: "Settings" },
{ to: "/diagnostics", label: "System Check" }, { to: "/diagnostics", label: "System Check" },
]; ];
@@ -12,6 +14,7 @@ const NAV = [
export default function Sidebar() { export default function Sidebar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { currentCampaign, setCampaign, campaigns } = useCampaign(); const { currentCampaign, setCampaign, campaigns } = useCampaign();
const { user, logout } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const nav = useNavigate(); const nav = useNavigate();
@@ -90,6 +93,15 @@ export default function Sidebar() {
); );
})} })}
</nav> </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> </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>
);
}