feat: Initialize FastAPI AI Gateway project structure with authentication, module management, and LLM API routing.

This commit is contained in:
2026-01-28 03:24:04 +08:00
commit b88dfec5fd
26 changed files with 1691 additions and 0 deletions

0
app/__init__.py Normal file
View File

90
app/api/admin_backend.py Normal file
View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.module import Module
import secrets
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
router = APIRouter()
class ModuleCreate(BaseModel):
name: str
program: Optional[str] = None
lob: Optional[str] = None
job_code: Optional[str] = None
class ModuleResponse(BaseModel):
id: int
name: str
secret_key: str
program: Optional[str]
lob: Optional[str]
job_code: Optional[str]
is_active: bool
created_at: datetime
last_rotated_at: datetime
class Config:
from_attributes = True
@router.post("/modules", response_model=ModuleResponse)
def create_module(
module_in: ModuleCreate,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user)
):
db_module = db.query(Module).filter(Module.name == module_in.name).first()
if db_module:
raise HTTPException(status_code=400, detail="Module already exists")
new_key = secrets.token_hex(32)
db_module = Module(
name=module_in.name,
secret_key=new_key,
program=module_in.program,
lob=module_in.lob,
job_code=module_in.job_code
)
db.add(db_module)
db.commit()
db.refresh(db_module)
return db_module
@router.get("/modules", response_model=List[ModuleResponse])
def get_modules(
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user)
):
return db.query(Module).all()
@router.post("/modules/{module_id}/rotate", response_model=ModuleResponse)
def rotate_module_key(
module_id: int,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user)
):
db_module = db.query(Module).filter(Module.id == module_id).first()
if not db_module:
raise HTTPException(status_code=404, detail="Module not found")
db_module.secret_key = secrets.token_hex(32)
db_module.last_rotated_at = datetime.utcnow()
db.commit()
db.refresh(db_module)
return db_module
@router.delete("/modules/{module_id}")
def delete_module(
module_id: int,
db: Session = Depends(get_db),
current_user: str = Depends(get_current_user)
):
db_module = db.query(Module).filter(Module.id == module_id).first()
if not db_module:
raise HTTPException(status_code=404, detail="Module not found")
db.delete(db_module)
db.commit()
return {"status": "success"}

43
app/api/deps.py Normal file
View File

@@ -0,0 +1,43 @@
from fastapi import Security, HTTPException, status, Depends
from fastapi.security.api_key import APIKeyHeader
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.models.module import Module
from cachetools import TTLCache
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Cache keys for 5 minutes, store up to 1000 keys
# This prevents a Supabase round-trip on every message
auth_cache = TTLCache(maxsize=1000, ttl=300)
async def get_api_key(
api_key_header: str = Security(api_key_header),
db: Session = Depends(get_db)
):
if not api_key_header:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="API Key missing"
)
# 1. Fallback to global static key (Admin)
if api_key_header == settings.API_KEY:
return api_key_header
# 2. Check Cache first (VERY FAST)
if api_key_header in auth_cache:
return api_key_header
# 3. Check Database for Module key (Database round-trip)
module = db.query(Module).filter(Module.secret_key == api_key_header, Module.is_active == True).first()
if module:
# Save to cache for next time
auth_cache[api_key_header] = True
return api_key_header
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials or API Key is inactive"
)

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import get_api_key
from app.models.module import Module
import secrets
from pydantic import BaseModel
from datetime import datetime
router = APIRouter()
class ModuleCreate(BaseModel):
name: str
class ModuleResponse(BaseModel):
id: int
name: str
secret_key: str
is_active: bool
class Config:
from_attributes = True
@router.post("/modules", response_model=ModuleResponse)
def create_module(
module_in: ModuleCreate,
db: Session = Depends(get_db),
api_key: str = Depends(get_api_key) # Only global admin key should be allowed here ideally
):
# Check if exists
db_module = db.query(Module).filter(Module.name == module_in.name).first()
if db_module:
raise HTTPException(status_code=400, detail="Module already exists")
new_key = secrets.token_hex(32)
db_module = Module(
name=module_in.name,
secret_key=new_key
)
db.add(db_module)
db.commit()
db.refresh(db_module)
return db_module
@router.post("/modules/{module_id}/rotate", response_model=ModuleResponse)
def rotate_module_key(
module_id: int,
db: Session = Depends(get_db),
api_key: str = Depends(get_api_key)
):
db_module = db.query(Module).filter(Module.id == module_id).first()
if not db_module:
raise HTTPException(status_code=404, detail="Module not found")
db_module.secret_key = secrets.token_hex(32)
db_module.last_rotated_at = datetime.utcnow()
db.commit()
db.refresh(db_module)
return db_module
@router.get("/modules", response_model=list[ModuleResponse])
def get_modules(
db: Session = Depends(get_db),
api_key: str = Depends(get_api_key)
):
return db.query(Module).all()

26
app/api/endpoints/auth.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.core import security
from app.core.config import settings
from pydantic import BaseModel
router = APIRouter()
class Token(BaseModel):
access_token: str
token_type: str
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
# Simple admin check - in a real app, query a User table
if form_data.username == "admin" and form_data.password == settings.ADMIN_PASSWORD:
access_token = security.create_access_token(
data={"sub": form_data.username}
)
return {"access_token": access_token, "token_type": "bearer"}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, Request
from app.api.deps import get_api_key
from app.core.limiter import limiter
from app.core.config import settings
from pydantic import BaseModel
from google import genai
import asyncio
router = APIRouter()
class LLMRequest(BaseModel):
prompt: str
context: str = ""
# Shared client instance (global)
_client = None
def get_gemini_client():
global _client
if _client is None and settings.GOOGLE_API_KEY and settings.GOOGLE_API_KEY != "your-google-api-key":
_client = genai.Client(api_key=settings.GOOGLE_API_KEY, http_options={'api_version': 'v1alpha'})
return _client
@router.post("/chat")
@limiter.limit(settings.RATE_LIMIT)
async def gemini_chat(
request: Request,
chat_data: LLMRequest,
api_key: str = Depends(get_api_key)
):
client = get_gemini_client()
try:
if not client:
return {
"status": "mock",
"model": "gemini",
"response": f"MOCK: Gemini response to '{chat_data.prompt}'"
}
# Using the async generation method provided by the new google-genai library
# We use await to ensure we don't block the event loop
response = await client.aio.models.generate_content(
model="gemini-2.0-flash",
contents=chat_data.prompt
)
return {
"status": "success",
"model": "gemini",
"response": response.text
}
except Exception as e:
return {"status": "error", "detail": str(e)}

View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, Request
from app.api.deps import get_api_key
from app.core.limiter import limiter
from app.core.config import settings
from pydantic import BaseModel
from openai import AsyncOpenAI
import asyncio
router = APIRouter()
class LLMRequest(BaseModel):
prompt: str
context: str = ""
# Initialize Async client
client = None
if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key":
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
@router.post("/chat")
@limiter.limit(settings.RATE_LIMIT)
async def openai_chat(
request: Request,
chat_data: LLMRequest,
api_key: str = Depends(get_api_key)
):
try:
if not client:
return {
"status": "mock",
"model": "openai",
"response": f"MOCK: OpenAI response to '{chat_data.prompt}'"
}
# Perform Async call to OpenAI
response = await client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": chat_data.prompt}]
)
return {
"status": "success",
"model": "openai",
"response": response.choices[0].message.content
}
except Exception as e:
return {"status": "error", "detail": str(e)}

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, Request
from app.api.deps import get_api_key
from app.core.limiter import limiter
from app.core.config import settings
from pydantic import BaseModel
router = APIRouter()
class ChatRequest(BaseModel):
prompt: str
context: str = ""
@router.post("/chat")
@limiter.limit(settings.RATE_LIMIT)
async def story_chat(
request: Request,
chat_data: ChatRequest,
api_key: str = Depends(get_api_key)
):
# This is where you would call your LLM (OpenAI, Anthropic, etc.)
# For now, we return a mock response
return {
"status": "success",
"response": f"Processed prompt: {chat_data.prompt}",
"metadata": {
"characters_received": len(chat_data.prompt),
"context_length": len(chat_data.context)
}
}
@router.get("/health")
async def health_check():
return {"status": "healthy"}

7
app/api/router.py Normal file
View File

@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.endpoints import storyline, gemini, openai
api_router = APIRouter()
api_router.include_router(storyline.router, prefix="/storyline", tags=["storyline"])
api_router.include_router(gemini.router, prefix="/gemini", tags=["gemini"])
api_router.include_router(openai.router, prefix="/openai", tags=["openai"])

18
app/core/config.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Settings:
PROJECT_NAME: str = "Storyline AI Gateway"
API_KEY: str = os.getenv("API_KEY", "storyline-secret-key-123")
RATE_LIMIT: str = "20/minute"
GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY")
OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY")
DATABASE_URL: str = os.getenv("DATABASE_URL")
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-super-secret-key-change-me")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123")
settings = Settings()

24
app/core/database.py Normal file
View File

@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# For development, we can use SQLite if DATABASE_URL is not set or for simplicity
# But we'll follow the user's request for PostgreSQL
SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL or "sqlite:///./sql_app.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
# check_same_thread is only needed for SQLite
**({"connect_args": {"check_same_thread": False}} if "sqlite" in SQLALCHEMY_DATABASE_URL else {})
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

4
app/core/limiter.py Normal file
View File

@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)

41
app/core/security.py Normal file
View File

@@ -0,0 +1,41 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
return username

56
app/main.py Normal file
View File

@@ -0,0 +1,56 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.api.router import api_router
from app.core.config import settings
from app.core.limiter import limiter
from app.core.database import engine, Base
from app.models import module # Ensure models are loaded
# Create tables
Base.metadata.create_all(bind=engine)
from fastapi.staticfiles import StaticFiles
from app.api.endpoints import auth
from app.api import admin_backend
def create_application() -> FastAPI:
application = FastAPI(title=settings.PROJECT_NAME)
# Mount Static Files
application.mount("/static", StaticFiles(directory="app/static"), name="static")
# Set up CORS
application.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Set up Rate Limiter
application.state.limiter = limiter
application.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Include routes
application.include_router(api_router, prefix="/api/v1")
application.include_router(auth.router, prefix="/auth", tags=["auth"])
application.include_router(admin_backend.router, prefix="/internal/admin", tags=["internal-admin"])
return application
app = create_application()
from fastapi.responses import FileResponse
import os
@app.get("/admin")
async def admin_panel():
return FileResponse("app/static/admin.html")
@app.get("/")
async def root():
return {"message": "Welcome to Storyline AI Gateway", "docs": "/docs"}

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
from app.models.module import Module

16
app/models/module.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import datetime
from app.core.database import Base
class Module(Base):
__tablename__ = "modules"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
secret_key = Column(String, unique=True, index=True)
program = Column(String, nullable=True)
lob = Column(String, nullable=True)
job_code = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_rotated_at = Column(DateTime, default=datetime.utcnow)

665
app/static/admin.html Normal file
View File

@@ -0,0 +1,665 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storyline AI Gateway - Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--bg: #0f172a;
--card-bg: #1e293b;
--text-main: #f8fafc;
--text-dim: #94a3b8;
--glass: rgba(30, 41, 59, 0.7);
--border: rgba(255, 255, 255, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Outfit', sans-serif;
}
body {
background-color: var(--bg);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
.bg-gradient {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 50%, #1e1b4b 0%, #0f172a 100%);
z-index: -1;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
width: 100%;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
animation: fadeInDown 0.8s ease-out;
}
.logo {
font-size: 1.5rem;
font-weight: 800;
background: linear-gradient(to right, #818cf8, #c084fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Login Section */
#login-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
}
.login-card {
background: var(--glass);
backdrop-filter: blur(12px);
padding: 3rem;
border-radius: 24px;
border: 1px solid var(--border);
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: scaleUp 0.5s ease-out;
}
.login-card h2 {
margin-bottom: 2rem;
text-align: center;
font-weight: 600;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-dim);
font-size: 0.875rem;
}
input {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.5);
border: 1px solid var(--border);
border-radius: 12px;
color: white;
transition: all 0.3s ease;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2);
}
button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.5);
}
/* Dashboard Section */
#dashboard-section {
display: none;
}
.card {
background: var(--glass);
backdrop-filter: blur(12px);
padding: 2rem;
border-radius: 20px;
border: 1px solid var(--border);
margin-bottom: 2rem;
animation: fadeInUp 0.5s ease-out;
}
.flex-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.module-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.module-card {
background: rgba(15, 23, 42, 0.3);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.module-card:hover {
border-color: rgba(99, 102, 241, 0.4);
transform: translateY(-5px);
}
.module-name {
font-weight: 600;
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.key-box {
background: #0f172a;
padding: 1rem 0.75rem;
border-radius: 12px;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
color: #818cf8;
margin: 1.25rem 0;
word-break: break-all;
border: 1px solid var(--border);
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.key-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.key-text.visible {
white-space: normal;
word-break: break-all;
}
.icon-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.icon-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 6px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.icon-btn:hover {
background: rgba(99, 102, 241, 0.1);
border-color: var(--primary);
color: var(--primary);
transform: scale(1.05);
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.btn-small {
padding: 0.6rem 1.2rem;
font-size: 0.85rem;
width: auto;
border-radius: 10px;
}
.btn-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: white;
}
.btn-danger {
background: #ef4444;
}
.btn-danger:hover {
background: #dc2626;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleUp {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: var(--card-bg);
padding: 2.5rem;
border-radius: 24px;
border: 1px solid var(--border);
width: 90%;
max-width: 500px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-content h2 {
margin-bottom: 2rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.module-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.badge {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 600;
}
.hidden {
display: none !important;
}
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 1rem 2rem;
background: var(--primary);
border-radius: 12px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
transform: translateY(100px);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
}
.toast.show {
transform: translateY(0);
}
</style>
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<header>
<div class="logo">Storyline Gateway Admin</div>
<button id="logout-btn" class="btn-small btn-outline hidden">Logout</button>
</header>
<!-- Login Section -->
<section id="login-section">
<div class="login-card">
<h2>Admin Login</h2>
<div class="form-group">
<label>Username</label>
<input type="text" id="username" value="admin">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" placeholder="••••••••">
</div>
<button id="login-btn">Secure Login</button>
</div>
</section>
<!-- Dashboard Section -->
<section id="dashboard-section">
<div class="card">
<div class="flex-header">
<h2>E-Learning Modules</h2>
<button id="add-module-btn" class="btn-small">Create New Module</button>
</div>
<div id="module-list" class="module-grid">
<!-- Modules will be injected here -->
</div>
</div>
</section>
</div>
<!-- Create Module Modal -->
<div id="create-modal" class="modal hidden">
<div class="modal-content">
<h2>Create New Module</h2>
<div class="form-group">
<label>Module Name (Unique)</label>
<input type="text" id="new-module-name" placeholder="e.g. Onboarding 2024">
</div>
<div class="form-grid">
<div class="form-group">
<label>Program</label>
<input type="text" id="new-module-program" placeholder="e.g. Sales Training">
</div>
<div class="form-group">
<label>LOB (Line of Business)</label>
<input type="text" id="new-module-lob" placeholder="e.g. Retail">
</div>
</div>
<div class="form-group">
<label>Job Code</label>
<input type="text" id="new-module-job-code" placeholder="e.g. JC-990">
</div>
<div class="modal-actions">
<button id="cancel-create-btn" class="btn-small btn-outline">Cancel</button>
<button id="save-module-btn" class="btn-small">Create Module</button>
</div>
</div>
</div>
<div id="toast" class="toast">Action successful!</div>
<script>
let TOKEN = localStorage.getItem('admin_token');
const loginSection = document.getElementById('login-section');
const dashboardSection = document.getElementById('dashboard-section');
const logoutBtn = document.getElementById('logout-btn');
const moduleList = document.getElementById('module-list');
function showToast(msg) {
const toast = document.getElementById('toast');
toast.innerText = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
async function apiRequest(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: {
'Authorization': `Bearer ${TOKEN}`
}
};
if (body) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
if (response.status === 401) {
logout();
return null;
}
return response.json();
}
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch('/auth/login', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.access_token) {
TOKEN = data.access_token;
localStorage.setItem('admin_token', TOKEN);
showDashboard();
} else {
alert('Invalid credentials');
}
} catch (error) {
console.error('Login failed', error);
}
}
function logout() {
localStorage.removeItem('admin_token');
TOKEN = null;
loginSection.classList.remove('hidden');
dashboardSection.style.display = 'none';
logoutBtn.classList.add('hidden');
}
async function showDashboard() {
loginSection.classList.add('hidden');
dashboardSection.style.display = 'block';
logoutBtn.classList.remove('hidden');
loadModules();
}
async function loadModules() {
const modules = await apiRequest('/internal/admin/modules');
if (!modules) return;
moduleList.innerHTML = modules.map(m => `
<div class="module-card">
<div class="module-name">${m.name}</div>
<div class="text-dim" style="font-size: 0.8rem">Created: ${new Date(m.created_at).toLocaleDateString()}</div>
<div class="module-meta">
${m.program ? `<span class="badge" title="Program">${m.program}</span>` : ''}
${m.lob ? `<span class="badge" title="Line of Business">${m.lob}</span>` : ''}
${m.job_code ? `<span class="badge" title="Job Code">${m.job_code}</span>` : ''}
</div>
<div class="key-box">
<span class="key-text" id="key-${m.id}">••••••••••••••••••••••••••••••••</span>
<div class="icon-actions">
<button class="icon-btn" onclick="toggleKey(${m.id}, '${m.secret_key}')" title="Show/Hide Key">
<svg id="eye-icon-${m.id}" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
<button class="icon-btn" onclick="copyKey('${m.secret_key}')" title="Copy Key">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
</button>
</div>
</div>
<div class="actions">
<button class="btn-small btn-outline" onclick="rotateKey(${m.id})">Rotate Key</button>
<button class="btn-small btn-danger" onclick="deleteModule(${m.id})">Delete</button>
</div>
</div>
`).join('');
}
// Modal Logic
const createModal = document.getElementById('create-modal');
const openModalBtn = document.getElementById('add-module-btn');
const cancelModalBtn = document.getElementById('cancel-create-btn');
const saveModuleBtn = document.getElementById('save-module-btn');
openModalBtn.onclick = () => {
createModal.classList.remove('hidden');
document.getElementById('new-module-name').value = '';
document.getElementById('new-module-program').value = '';
document.getElementById('new-module-lob').value = '';
document.getElementById('new-module-job-code').value = '';
};
cancelModalBtn.onclick = () => createModal.classList.add('hidden');
saveModuleBtn.onclick = async () => {
const moduleData = {
name: document.getElementById('new-module-name').value,
program: document.getElementById('new-module-program').value,
lob: document.getElementById('new-module-lob').value,
job_code: document.getElementById('new-module-job-code').value
};
if (!moduleData.name) {
alert('Module name is required');
return;
}
const res = await apiRequest('/internal/admin/modules', 'POST', moduleData);
if (res && res.detail) {
alert(res.detail);
} else if (res) {
showToast('Module created successfully');
createModal.classList.add('hidden');
loadModules();
}
};
window.toggleKey = (id, key) => {
const el = document.getElementById(`key-${id}`);
const icon = document.getElementById(`eye-icon-${id}`);
const isHidden = el.innerText.includes('•');
if (isHidden) {
el.innerText = key;
el.classList.add('visible');
icon.innerHTML = '<path d="M9.88 9.88 2 20"></path><path d="M2 12s3-7 10-7a9.77 9.77 0 0 1 5 1.45"></path><path d="M22 12s-3 7-10 7a9.77 9.77 0 0 1-5-1.45"></path><path d="M15.12 15.12a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line>';
} else {
el.innerText = '••••••••••••••••••••••••••••••••';
el.classList.remove('visible');
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>';
}
};
window.copyKey = (key) => {
navigator.clipboard.writeText(key);
showToast('Key copied to clipboard!');
};
window.rotateKey = async (id) => {
if (confirm('Are you sure? Previous key will stop working immediately.')) {
await apiRequest(`/internal/admin/modules/${id}/rotate`, 'POST');
showToast('Key rotated successfully');
loadModules();
}
};
window.deleteModule = async (id) => {
if (confirm('Delete this module? This cannot be undone.')) {
await apiRequest(`/internal/admin/modules/${id}`, 'DELETE');
showToast('Module deleted');
loadModules();
}
};
document.getElementById('login-btn').onclick = login;
document.getElementById('login-btn').onclick = login;
logoutBtn.onclick = logout;
// Auto-login check
if (TOKEN) {
showDashboard();
}
</script>
</body>
</html>