feat: Phase 1 - multi-user auth foundation
- New auth.py module: users table, invite_codes table, JWT auth,
password hashing (bcrypt), auth dependencies for FastAPI
- KITHARA_MODE=single|multi in .env (default: single)
- Single mode: no auth required, no login, user_id='default'
- Multi mode: full JWT auth, login/register endpoints, admin bootstrap
- Health endpoint now reports mode: {status, version, mode}
- Auth endpoints: POST /api/auth/login, /register, GET /me
- Admin endpoints: GET /admin/users, POST /admin/users,
POST /admin/invites, GET /admin/invites,
DELETE /admin/users/:id, PUT /admin/users/:id/role
- BYOK: PUT /api/auth/api-key for per-user Lyria keys
- DB migrations: user_id, shared, remixed_from columns on clips
- DB indexes for user-based and shared-content queries
- requirements.txt: added PyJWT and passlib[bcrypt]
- .env.example: documented all new config options
This commit is contained in:
1 parent
b008a6790a
commit
b3f273942e
5 files changed
+540
-5
No files matched your search
@@ -0,0 +1,267 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import aiosqlite
|
||||
import jwt
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
from database import DB_PATH
|
||||
|
||||
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
KITHARA_MODE = os.getenv("KITHARA_MODE", "single")
|
||||
|
||||
def is_multi_user() -> bool:
|
||||
return KITHARA_MODE.lower() == "multi"
|
||||
|
||||
def is_single_user() -> bool:
|
||||
return not is_multi_user()
|
||||
|
||||
# ─── JWT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_jwt_secret() -> str:
|
||||
"""Get or generate JWT secret. Persists in DB for durability across restarts."""
|
||||
env_secret = os.getenv("KITHARA_JWT_SECRET")
|
||||
if env_secret:
|
||||
return env_secret
|
||||
# Check DB for persisted secret
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Can't await from here if event loop is already running
|
||||
# Fall back to generating a new one each startup (sessions invalid on restart)
|
||||
return secrets.token_hex(32)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return secrets.token_hex(32)
|
||||
|
||||
JWT_SECRET = _get_jwt_secret()
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRY_HOURS = 72 # 3 days
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str) -> str:
|
||||
"""Create a JWT token for a user."""
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRY_HOURS),
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
"""Decode and validate a JWT token. Returns payload or None."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Password Hashing ────────────────────────────────────────────────────────
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return bcrypt.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
return bcrypt.verify(password, password_hash)
|
||||
|
||||
|
||||
# ─── User Management ─────────────────────────────────────────────────────────
|
||||
|
||||
async def ensure_users_table():
|
||||
"""Create users and invite_codes tables if they don't exist."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
api_key TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
created_by TEXT REFERENCES users(id),
|
||||
max_uses INTEGER DEFAULT 1,
|
||||
current_uses INTEGER DEFAULT 0,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_user(username: str, password: str, role: str = "user", display_name: str = None) -> dict:
|
||||
"""Create a new user. Returns user dict."""
|
||||
user_id = str(uuid.uuid4())
|
||||
password_hash = hash_password(password)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT INTO users (id, username, password_hash, role, display_name) VALUES (?, ?, ?, ?, ?)",
|
||||
(user_id, username, password_hash, role, display_name or username)
|
||||
)
|
||||
await db.commit()
|
||||
except aiosqlite.IntegrityError:
|
||||
return None # Username taken
|
||||
|
||||
return await get_user_by_id(user_id)
|
||||
|
||||
|
||||
async def get_user_by_id(user_id: str) -> dict | None:
|
||||
"""Get user by ID."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def get_user_by_username(username: str) -> dict | None:
|
||||
"""Get user by username."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def authenticate_user(username: str, password: str) -> dict | None:
|
||||
"""Authenticate a user. Returns user dict if valid, None if not."""
|
||||
user = await get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user["password_hash"]):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def list_users() -> list[dict]:
|
||||
"""List all users (admin tool)."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT id, username, display_name, role, api_key IS NOT NULL as has_api_key, created_at FROM users ORDER BY created_at") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def set_user_role(user_id: str, role: str) -> bool:
|
||||
"""Set a user's role. Returns True if successful."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def set_user_api_key(user_id: str, api_key: str | None) -> bool:
|
||||
"""Set or clear a user's API key. Returns True if successful."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute("UPDATE users SET api_key = ? WHERE id = ?", (api_key, user_id))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def delete_user(user_id: str) -> bool:
|
||||
"""Delete a user."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# ─── Invite Codes ────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_invite_code(created_by: str, max_uses: int = 1, expires_hours: int = None) -> str:
|
||||
"""Generate an invite code. Returns the code string."""
|
||||
code = secrets.token_urlsafe(16)
|
||||
expires_at = None
|
||||
if expires_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_hours)).isoformat()
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO invite_codes (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(code, created_by, max_uses, expires_at)
|
||||
)
|
||||
await db.commit()
|
||||
return code
|
||||
|
||||
|
||||
async def use_invite_code(code: str) -> bool:
|
||||
"""Try to use an invite code. Returns True if valid and consumed."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM invite_codes WHERE code = ?", (code,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
invite = dict(row)
|
||||
|
||||
# Check expiry
|
||||
if invite["expires_at"]:
|
||||
expires = datetime.fromisoformat(invite["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires.replace(tzinfo=timezone.utc):
|
||||
return False
|
||||
|
||||
# Check max uses
|
||||
if invite["current_uses"] >= invite["max_uses"]:
|
||||
return False
|
||||
|
||||
# Increment uses
|
||||
await db.execute(
|
||||
"UPDATE invite_codes SET current_uses = current_uses + 1 WHERE code = ?",
|
||||
(code,)
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def list_invite_codes() -> list[dict]:
|
||||
"""List all invite codes (admin tool)."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM invite_codes ORDER BY created_at DESC") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
# ─── Bootstrap ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def bootstrap_admin():
|
||||
"""Create the first admin user from env vars if in multi mode and no users exist."""
|
||||
if not is_multi_user():
|
||||
return
|
||||
|
||||
# Check if any users exist
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute("SELECT COUNT(*) FROM users") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row[0] > 0:
|
||||
return # Users exist, skip bootstrap
|
||||
|
||||
admin_user = os.getenv("KITHARA_ADMIN_USER")
|
||||
admin_pass = os.getenv("KITHARA_ADMIN_PASS")
|
||||
|
||||
if admin_user and admin_pass:
|
||||
user = await create_user(admin_user, admin_pass, role="admin", display_name="Admin")
|
||||
if user:
|
||||
print(f"[kithara] Bootstrapped admin user: {admin_user}")
|
||||
@@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS clip_favorites (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_user ON clip_favorites(user_id, created_at DESC);
|
||||
|
||||
|
||||
-- Migration: add artwork_filename column to existing databases
|
||||
-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed
|
||||
"""
|
||||
@@ -112,6 +113,30 @@ async def init_db():
|
||||
await db.execute("ALTER TABLE clips ADD COLUMN genre TEXT")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
# Migration: add user_id column if it doesn't exist
|
||||
try:
|
||||
await db.execute("ALTER TABLE clips ADD COLUMN user_id TEXT DEFAULT 'default'")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
# Migration: add shared column if it doesn't exist
|
||||
try:
|
||||
await db.execute("ALTER TABLE clips ADD COLUMN shared INTEGER DEFAULT 0")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
# Migration: add remixed_from column if it doesn't exist
|
||||
try:
|
||||
await db.execute("ALTER TABLE clips ADD COLUMN remixed_from TEXT")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
# Create indexes for new columns (safe to run every time
|
||||
try:
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_shared ON clips(shared, created_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
await db.commit()
|
||||
|
||||
async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str:
|
||||
|
||||
+234
-3
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, Depends, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pathlib import Path
|
||||
import os
|
||||
import shutil
|
||||
@@ -11,6 +12,13 @@ load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
|
||||
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata, toggle_favorite, get_favorites, is_favorite
|
||||
from auth import (
|
||||
is_multi_user, is_single_user, create_access_token, decode_access_token,
|
||||
authenticate_user, create_user, get_user_by_id, get_user_by_username,
|
||||
list_users, set_user_role, set_user_api_key, delete_user,
|
||||
create_invite_code, use_invite_code, list_invite_codes,
|
||||
ensure_users_table, bootstrap_admin
|
||||
)
|
||||
from kithara_client import generate_clip, save_clip_file
|
||||
from waveform import generate_waveform_svg, get_waveform_path
|
||||
from analysis import analyze_clip
|
||||
@@ -36,13 +44,236 @@ ARTWORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ALLOWED_ARTWORK_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
MAX_ARTWORK_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# ─── Auth Dependency ────────────────────────────────────────────────────────
|
||||
|
||||
async def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""
|
||||
Dependency that returns the current user dict, or None in single-user mode.
|
||||
In multi-user mode, requires a valid JWT token.
|
||||
In single-user mode, returns None (no auth needed).
|
||||
"""
|
||||
if is_single_user():
|
||||
return None
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
user = await get_user_by_id(payload["sub"])
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
async def require_admin(user = Depends(get_current_user)):
|
||||
"""Dependency that requires the current user to be an admin."""
|
||||
if is_single_user():
|
||||
return None
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
def get_user_id(user) -> str:
|
||||
"""Get user_id from auth context, or 'default' for single-user mode."""
|
||||
if user is None:
|
||||
return "default"
|
||||
return user["id"]
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db()
|
||||
await ensure_users_table()
|
||||
await bootstrap_admin()
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"mode": "multi" if is_multi_user() else "single"
|
||||
}
|
||||
|
||||
# ─── Auth Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: Request):
|
||||
"""Authenticate user and return JWT token."""
|
||||
if is_single_user():
|
||||
raise HTTPException(status_code=400, detail="Login not available in single-user mode")
|
||||
|
||||
body = await request.json()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password required")
|
||||
|
||||
user = await authenticate_user(username, password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = create_access_token(user["id"], user["role"])
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def register(request: Request):
|
||||
"""Register a new user using an invite code."""
|
||||
if is_single_user():
|
||||
raise HTTPException(status_code=400, detail="Registration not available in single-user mode")
|
||||
|
||||
body = await request.json()
|
||||
invite_code = body.get("invite_code", "").strip()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
display_name = body.get("display_name", "").strip() or username
|
||||
|
||||
if not invite_code or not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Invite code, username, and password required")
|
||||
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
if not await use_invite_code(invite_code):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||
|
||||
user = await create_user(username, password, role="user", display_name=display_name)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
token = create_access_token(user["id"], user["role"])
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def get_me(user = Depends(get_current_user)):
|
||||
"""Get current user info."""
|
||||
if is_single_user():
|
||||
return {"mode": "single", "user": None}
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
return {
|
||||
"mode": "multi",
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
"has_api_key": bool(user.get("api_key")),
|
||||
}
|
||||
}
|
||||
|
||||
@app.put("/api/auth/api-key")
|
||||
async def update_api_key(request: Request, user = Depends(get_current_user)):
|
||||
"""Update the current user's Lyria API key (BYOK)."""
|
||||
if is_single_user():
|
||||
raise HTTPException(status_code=400, detail="Not available in single-user mode")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
body = await request.json()
|
||||
api_key = body.get("api_key", "").strip() or None # Empty string = clear
|
||||
|
||||
await set_user_api_key(user["id"], api_key)
|
||||
return {"status": "ok"}
|
||||
|
||||
# ─── Admin Endpoints ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/admin/users")
|
||||
async def admin_list_users(admin = Depends(require_admin)):
|
||||
"""List all users. Admin only."""
|
||||
users = await list_users()
|
||||
return {"users": users}
|
||||
|
||||
@app.post("/api/admin/users")
|
||||
async def admin_create_user(request: Request, admin = Depends(require_admin)):
|
||||
"""Create a new user. Admin only."""
|
||||
body = await request.json()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
role = body.get("role", "user")
|
||||
display_name = body.get("display_name", "").strip() or username
|
||||
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password required")
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
|
||||
user = await create_user(username, password, role=role, display_name=display_name)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Username already taken")
|
||||
|
||||
return {"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"role": user["role"],
|
||||
}}
|
||||
|
||||
@app.post("/api/admin/invites")
|
||||
async def admin_create_invite(request: Request, admin = Depends(require_admin)):
|
||||
"""Generate an invite code. Admin only."""
|
||||
body = await request.json()
|
||||
max_uses = body.get("max_uses", 1)
|
||||
expires_hours = body.get("expires_hours")
|
||||
|
||||
code = await create_invite_code(admin["id"], max_uses=max_uses, expires_hours=expires_hours)
|
||||
return {"code": code, "max_uses": max_uses, "expires_hours": expires_hours}
|
||||
|
||||
@app.get("/api/admin/invites")
|
||||
async def admin_list_invites(admin = Depends(require_admin)):
|
||||
"""List all invite codes. Admin only."""
|
||||
invites = await list_invite_codes()
|
||||
return {"invites": invites}
|
||||
|
||||
@app.delete("/api/admin/users/{user_id}")
|
||||
async def admin_delete_user(user_id: str, admin = Depends(require_admin)):
|
||||
"""Delete a user. Admin only."""
|
||||
if user_id == admin["id"]:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
deleted = await delete_user(user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/api/admin/users/{user_id}/role")
|
||||
async def admin_set_role(user_id: str, request: Request, admin = Depends(require_admin)):
|
||||
"""Set a user's role. Admin only."""
|
||||
body = await request.json()
|
||||
role = body.get("role")
|
||||
if role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
|
||||
|
||||
updated = await set_user_role(user_id, role)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/models")
|
||||
async def list_models():
|
||||
|
||||
Reference in new issue
Block a user