This commit is contained in:
2026-01-25 14:36:01 +01:00
commit fdf1e0e8ed
22 changed files with 1269 additions and 0 deletions

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend ./backend
COPY frontend ./frontend
WORKDIR /app/backend
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

BIN
Nabd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

56
backend/auth_utils.py Normal file
View File

@@ -0,0 +1,56 @@
from passlib.context import CryptContext
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from . import models, schemas, database
# Configuration
SECRET_KEY = "ASF_SSO_SUPER_SECRET_KEY_CHANGE_THIS_IN_PROD"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
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, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(database.get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = schemas.TokenData(username=username)
except JWTError:
raise credentials_exception
user = db.query(models.User).filter(models.User.username == token_data.username).first()
if user is None:
raise credentials_exception
return user
async def get_current_admin_user(current_user: models.User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=400, detail="Inactive user or not admin")
return current_user

19
backend/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./sso.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

53
backend/main.py Normal file
View File

@@ -0,0 +1,53 @@
from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base, SessionLocal
from .routers import auth, users, apps, sso
from . import models, auth_utils
from sqlalchemy.orm import Session
# Create tables
Base.metadata.create_all(bind=engine)
app = FastAPI(title="ASF SSO Service")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(apps.router)
app.include_router(sso.router)
# Mount static files (Frontend)
app.mount("/", StaticFiles(directory="../frontend", html=True), name="static")
# Create initial admin user if not exists
def create_initial_admin():
db = SessionLocal()
try:
admin = db.query(models.User).filter(models.User.username == "admin").first()
if not admin:
print("Creating initial admin user...")
hashed_pwd = auth_utils.get_password_hash("admin") # Default password, should be changed
admin_user = models.User(
username="admin",
email="admin@nabd-co.com",
hashed_password=hashed_pwd,
is_admin=True,
is_active=True
)
db.add(admin_user)
db.commit()
print("Admin user created.")
finally:
db.close()
create_initial_admin()

40
backend/models.py Normal file
View File

@@ -0,0 +1,40 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
applications = relationship("UserApplication", back_populates="user")
class Application(Base):
__tablename__ = "applications"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True)
api_key = Column(String, unique=True, index=True) # Secret key for the app to talk to SSO
url = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
users = relationship("UserApplication", back_populates="application")
class UserApplication(Base):
__tablename__ = "user_applications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
application_id = Column(Integer, ForeignKey("applications.id"))
assigned_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="applications")
application = relationship("Application", back_populates="users")

33
backend/routers/apps.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
import secrets
from .. import database, models, schemas, auth_utils
router = APIRouter(
prefix="/apps",
tags=["Applications"],
dependencies=[Depends(auth_utils.get_current_admin_user)]
)
@router.post("/", response_model=schemas.ApplicationOut)
async def create_application(app: schemas.ApplicationCreate, db: Session = Depends(database.get_db)):
db_app = db.query(models.Application).filter(models.Application.name == app.name).first()
if db_app:
raise HTTPException(status_code=400, detail="Application already exists")
api_key = secrets.token_urlsafe(32)
db_app = models.Application(
name=app.name,
url=app.url,
api_key=api_key
)
db.add(db_app)
db.commit()
db.refresh(db_app)
return db_app
@router.get("/", response_model=List[schemas.ApplicationOut])
async def read_applications(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
apps = db.query(models.Application).offset(skip).limit(limit).all()
return apps

22
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from .. import database, models, auth_utils, schemas
from datetime import timedelta
router = APIRouter(tags=["Authentication"])
@router.post("/token", response_model=schemas.Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(database.get_db)):
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not auth_utils.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=auth_utils.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = auth_utils.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

31
backend/routers/sso.py Normal file
View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import database, models, schemas, auth_utils
router = APIRouter(tags=["SSO"])
@router.post("/verify", response_model=schemas.SSOVerifyResponse)
async def verify_user(request: schemas.SSOVerifyRequest, db: Session = Depends(database.get_db)):
# 1. Validate API Key
app = db.query(models.Application).filter(models.Application.api_key == request.api_key).first()
if not app:
raise HTTPException(status_code=403, detail="Invalid API Key")
# 2. Validate User Credentials
user = db.query(models.User).filter(models.User.username == request.username).first()
if not user or not auth_utils.verify_password(request.password, user.hashed_password):
return {"authorized": False, "message": "Invalid username or password"}
if not user.is_active:
return {"authorized": False, "message": "User account is inactive"}
# 3. Check Assignment
assignment = db.query(models.UserApplication).filter(
models.UserApplication.user_id == user.id,
models.UserApplication.application_id == app.id
).first()
if not assignment:
return {"authorized": False, "message": "User not authorized for this application"}
return {"authorized": True, "message": "Authorized", "user": user}

78
backend/routers/users.py Normal file
View File

@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List
from .. import database, models, schemas, auth_utils
from ..services import email
router = APIRouter(
prefix="/users",
tags=["Users"],
dependencies=[Depends(auth_utils.get_current_admin_user)]
)
@router.post("/", response_model=schemas.UserOut)
async def create_user(user: schemas.UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
db_user = db.query(models.User).filter(models.User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = auth_utils.get_password_hash(user.password)
db_user = models.User(
username=user.username,
email=user.email,
hashed_password=hashed_password,
is_active=user.is_active,
is_admin=user.is_admin
)
db.add(db_user)
db.commit()
db.refresh(db_user)
# Send email
background_tasks.add_task(email.send_welcome_email, user.email, user.username, user.password)
return db_user
@router.get("/", response_model=List[schemas.UserOut])
async def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
users = db.query(models.User).offset(skip).limit(limit).all()
return users
@router.put("/{user_id}", response_model=schemas.UserOut)
async def update_user(user_id: int, user_update: schemas.UserUpdate, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
if user_update.password:
db_user.hashed_password = auth_utils.get_password_hash(user_update.password)
# Send email on password change
background_tasks.add_task(email.send_welcome_email, db_user.email, db_user.username, user_update.password)
if user_update.email:
db_user.email = user_update.email
if user_update.username:
db_user.username = user_update.username
if user_update.is_active is not None:
db_user.is_active = user_update.is_active
if user_update.is_admin is not None:
db_user.is_admin = user_update.is_admin
db.commit()
db.refresh(db_user)
return db_user
@router.post("/{user_id}/assign/{app_id}")
async def assign_app_to_user(user_id: int, app_id: int, db: Session = Depends(database.get_db)):
assignment = db.query(models.UserApplication).filter(
models.UserApplication.user_id == user_id,
models.UserApplication.application_id == app_id
).first()
if assignment:
return {"message": "Already assigned"}
new_assignment = models.UserApplication(user_id=user_id, application_id=app_id)
db.add(new_assignment)
db.commit()
return {"message": "Assigned successfully"}

63
backend/schemas.py Normal file
View File

@@ -0,0 +1,63 @@
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from datetime import datetime
# User Schemas
class UserBase(BaseModel):
username: str
email: EmailStr
is_active: Optional[bool] = True
is_admin: Optional[bool] = False
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
class UserOut(UserBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
# Application Schemas
class ApplicationBase(BaseModel):
name: str
url: str
class ApplicationCreate(ApplicationBase):
pass
class ApplicationOut(ApplicationBase):
id: int
api_key: str
created_at: datetime
class Config:
orm_mode = True
# Token Schema
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
# SSO Verification Schema
class SSOVerifyRequest(BaseModel):
username: str
password: str
api_key: str
class SSOVerifyResponse(BaseModel):
authorized: bool
message: str
user: Optional[UserOut] = None

45
backend/services/email.py Normal file
View File

@@ -0,0 +1,45 @@
import aiosmtplib
from email.message import EmailMessage
import os
SMTP_HOST = os.getenv("SMTP_ADDRESS", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
SMTP_USER = os.getenv("SMTP_USER_NAME", "support@nabd-co.com")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "zwziglbpxyfogafc")
async def send_email(to_email: str, subject: str, body: str):
message = EmailMessage()
message["From"] = SMTP_USER
message["To"] = to_email
message["Subject"] = subject
message.set_content(body)
try:
await aiosmtplib.send(
message,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER,
password=SMTP_PASSWORD,
start_tls=True,
)
print(f"Email sent to {to_email}")
except Exception as e:
print(f"Failed to send email to {to_email}: {e}")
async def send_welcome_email(to_email: str, username: str, password: str):
subject = "Welcome to ASF SSO"
body = f"""
Hello {username},
Your account has been created/updated on the ASF SSO platform.
Username: {username}
Password: {password}
Please login to change your password if needed.
Regards,
ASF Team
"""
await send_email(to_email, subject, body)

7
caddy_config.txt Normal file
View File

@@ -0,0 +1,7 @@
# -------------------------
# SSO Service Proxy
# -------------------------
sso.nabd-co.com {
# Targets the container 'sso_service' on its internal port 8001
reverse_proxy sso_service:8001
}

15
deploy.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Build and start the container
docker-compose up -d --build
echo "SSO Service Deployed!"
echo "---------------------------------------------------"
echo "Please add the following to your Caddyfile:"
echo ""
echo "sso.nabd-co.com {"
echo " reverse_proxy sso_service:8001"
echo "}"
echo ""
echo "Then reload Caddy."
echo "---------------------------------------------------"

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
sso:
build: .
container_name: sso_service
restart: always
ports:
- "8001:8001"
volumes:
- sso_data:/app/backend
environment:
- SMTP_ADDRESS=smtp.gmail.com
- SMTP_PORT=587
- SMTP_USER_NAME=support@nabd-co.com
- SMTP_PASSWORD=zwziglbpxyfogafc
networks:
- sso_network
networks:
sso_network:
volumes:
sso_data:

140
docs/api_reference.md Normal file
View File

@@ -0,0 +1,140 @@
# ASF SSO API Reference
This document details the API endpoints available in the ASF SSO service.
## Base URL
`https://sso.nabd-co.com` (or `http://localhost:8001` for local dev)
---
## 1. SSO Verification (External Apps)
This is the primary endpoint used by external applications to authenticate users.
### `POST /verify`
**Description**: Verifies a user's credentials and checks if they are authorized for the calling application.
**Headers**:
- `Content-Type: application/json`
**Request Body**:
```json
{
"username": "jdoe",
"password": "secretpassword",
"api_key": "YOUR_APP_API_KEY"
}
```
**Response (Success - 200 OK)**:
```json
{
"authorized": true,
"message": "Authorized",
"user": {
"username": "jdoe",
"email": "jdoe@example.com",
"is_active": true,
"is_admin": false,
"id": 5,
"created_at": "2026-01-25T12:00:00",
"updated_at": "2026-01-25T12:00:00"
}
}
```
**Response (Failure - 200 OK)**:
*Note: The API returns 200 OK even for auth failures, but with `authorized: false`.*
```json
{
"authorized": false,
"message": "Invalid username or password"
// OR "User not authorized for this application"
// OR "User account is inactive"
}
```
**Example Usage (cURL)**:
```bash
curl -X POST https://sso.nabd-co.com/verify \
-H "Content-Type: application/json" \
-d '{
"username": "testuser",
"password": "password123",
"api_key": "abc123xyz"
}'
```
---
## 2. Admin Authentication
These endpoints are for the Admin Dashboard.
### `POST /token`
**Description**: Login as an administrator to get an access token.
**Request Body (Form Data)**:
- `username`: admin
- `password`: admin_password
**Response**:
```json
{
"access_token": "eyJhbGciOiJIUzI1Ni...",
"token_type": "bearer"
}
```
---
## 3. User Management (Admin Only)
**Requires Header**: `Authorization: Bearer <access_token>`
### `GET /users/`
**Description**: List all users.
### `POST /users/`
**Description**: Create a new user.
**Body**:
```json
{
"username": "newuser",
"email": "user@example.com",
"password": "password123",
"is_admin": false
}
```
### `PUT /users/{user_id}`
**Description**: Update a user.
**Body**:
```json
{
"email": "newemail@example.com",
"is_active": false
}
```
### `POST /users/{user_id}/assign/{app_id}`
**Description**: Assign a user to an application.
---
## 4. Application Management (Admin Only)
**Requires Header**: `Authorization: Bearer <access_token>`
### `GET /apps/`
**Description**: List all registered applications.
### `POST /apps/`
**Description**: Register a new application.
**Body**:
```json
{
"name": "OpenProject",
"url": "https://openproject.nabd-co.com"
}
```
**Response**:
Returns the created app object, including the **`api_key`**.

61
docs/user_guide.md Normal file
View File

@@ -0,0 +1,61 @@
# ASF SSO Application - User Guide
## Overview
The **ASF SSO (Single Sign-On)** application is a centralized authentication service designed to manage user access across multiple web applications within the ASF ecosystem. It provides a secure and unified way to handle user credentials and application permissions.
## Key Features
- **Centralized User Management**: Create, update, and manage users from a single admin portal.
- **Application Management**: Register new applications and generate secure API keys.
- **Access Control**: Assign specific users to specific applications.
- **SSO Verification**: Secure API for external applications to verify user credentials and access rights.
- **Email Notifications**: Automatically sends welcome emails and password update notifications to users.
- **Modern UI**: A responsive, dark-themed dashboard for administrators.
## Architecture
- **Backend**: Python FastAPI (High performance, easy to maintain).
- **Database**: SQLite (Self-contained, easy to backup).
- **Frontend**: Vanilla HTML/CSS/JavaScript (Lightweight, no build step required).
- **Deployment**: Docker & Docker Compose (Containerized for consistency).
## Workflows
### 1. Admin Login
The application is protected by an admin login.
- **URL**: `https://sso.nabd-co.com`
- **Default Credentials**: `admin` / `admin` (Change this immediately after first login).
### 2. Managing Users
- **Create User**:
1. Navigate to the **Users** tab.
2. Click **Add User**.
3. Enter Username, Email, and Password.
4. Click **Save**.
5. *Result*: The user is created, and a welcome email is sent to them.
- **Edit User**: Click **Edit** next to a user to update their details or reset their password.
### 3. Managing Applications
- **Register Application**:
1. Navigate to the **Applications** tab.
2. Click **Add Application**.
3. Enter the Application Name and URL.
4. Click **Save**.
5. *Result*: The application is listed, and a unique **API Key** is generated.
6. **Important**: Copy the API Key. You will need to configure it in the external application.
### 4. Assigning Access
Users cannot log in to an application unless they are explicitly assigned to it.
1. Go to the **Users** tab.
2. Click **Assign App** next to the user.
3. Select the target application from the dropdown.
4. Click **Assign**.
## Integration Logic
When a user tries to log in to an external application (e.g., OpenProject):
1. The external app collects the username and password from the user.
2. The external app sends a secure request to the SSO `verify` endpoint.
3. The SSO service checks:
- Is the API Key valid?
- Are the username and password correct?
- Is the user assigned to this application?
- Is the user account active?
4. If all checks pass, SSO returns `Authorized`. Otherwise, it returns `Unauthorized`.

150
frontend/index.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASF SSO - Admin Portal</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- Login Page -->
<div id="login-page" class="flex items-center justify-center" style="height: 100vh;">
<div class="card text-center" style="width: 100%; max-width: 400px;">
<img src="/static/img/logo.png" alt="ASF Logo" class="logo" style="height: 80px; margin-bottom: 2rem;">
<h2 class="text-2xl mb-4">Admin Login</h2>
<form id="login-form">
<input type="text" id="username" placeholder="Username" required>
<input type="password" id="password" placeholder="Password" required>
<button type="submit" class="btn btn-primary w-full">Login</button>
</form>
<p id="login-error" class="text-accent mt-4 hidden"></p>
</div>
</div>
<!-- Dashboard Layout -->
<div id="dashboard-layout" class="hidden">
<header class="flex items-center justify-between">
<div class="flex items-center">
<img src="/static/img/logo.png" alt="ASF Logo" class="logo">
<h1 class="text-xl">ASF SSO Admin</h1>
</div>
<button id="logout-btn" class="btn btn-secondary">Logout</button>
</header>
<main class="p-8">
<!-- Tabs -->
<div class="flex gap-4 mb-8">
<button class="btn btn-primary" onclick="showSection('users')">Users</button>
<button class="btn btn-secondary" onclick="showSection('apps')">Applications</button>
</div>
<!-- Users Section -->
<section id="users-section">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl">Users</h2>
<button class="btn btn-primary" onclick="openModal('user-modal')">Add User</button>
</div>
<div class="card">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Users will be populated here -->
</tbody>
</table>
</div>
</section>
<!-- Applications Section -->
<section id="apps-section" class="hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl">Applications</h2>
<button class="btn btn-primary" onclick="openModal('app-modal')">Add Application</button>
</div>
<div class="card">
<table id="apps-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>URL</th>
<th>API Key</th>
</tr>
</thead>
<tbody>
<!-- Apps will be populated here -->
</tbody>
</table>
</div>
</section>
</main>
</div>
<!-- User Modal -->
<div id="user-modal" class="modal-overlay hidden">
<div class="card modal">
<h3 class="text-xl mb-4">Add/Edit User</h3>
<form id="user-form">
<input type="hidden" id="user-id">
<input type="text" id="user-username" placeholder="Username" required>
<input type="email" id="user-email" placeholder="Email" required>
<input type="password" id="user-password" placeholder="Password (leave blank to keep current)">
<div class="flex items-center gap-4 mb-4">
<label class="flex items-center gap-2">
<input type="checkbox" id="user-is-admin" style="width: auto; margin: 0;"> Admin
</label>
<label class="flex items-center gap-2">
<input type="checkbox" id="user-is-active" style="width: auto; margin: 0;" checked> Active
</label>
</div>
<div class="flex justify-end gap-4">
<button type="button" class="btn btn-secondary" onclick="closeModal('user-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- App Modal -->
<div id="app-modal" class="modal-overlay hidden">
<div class="card modal">
<h3 class="text-xl mb-4">Add Application</h3>
<form id="app-form">
<input type="text" id="app-name" placeholder="Application Name" required>
<input type="url" id="app-url" placeholder="Application URL" required>
<div class="flex justify-end gap-4">
<button type="button" class="btn btn-secondary" onclick="closeModal('app-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Assign App Modal -->
<div id="assign-modal" class="modal-overlay hidden">
<div class="card modal">
<h3 class="text-xl mb-4">Assign Application</h3>
<form id="assign-form">
<input type="hidden" id="assign-user-id">
<select id="assign-app-select" required>
<option value="">Select Application</option>
</select>
<div class="flex justify-end gap-4">
<button type="button" class="btn btn-secondary" onclick="closeModal('assign-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Assign</button>
</div>
</form>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,152 @@
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--background-color: #0f172a; /* Dark Blue */
--surface-color: #1e293b; /* Lighter Dark Blue */
--text-color: #f8fafc;
--text-muted: #94a3b8;
--accent-color: #38bdf8;
--glass-bg: rgba(30, 41, 59, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: var(--background-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Utilities */
.hidden { display: none !important; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-4 { gap: 1rem; }
.p-4 { padding: 1rem; }
.p-8 { padding: 2rem; }
.m-4 { margin: 1rem; }
.mt-4 { margin-top: 1rem; }
.w-full { width: 100%; }
.text-center { text-align: center; }
.text-xl { font-size: 1.25rem; font-weight: bold; }
.text-2xl { font-size: 1.5rem; font-weight: bold; }
.text-accent { color: var(--accent-color); }
/* Glassmorphism Card */
.card {
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: var(--surface-color);
color: var(--text-color);
border: 1px solid var(--glass-border);
}
.btn-secondary:hover {
background-color: #334155;
}
/* Inputs */
input, select {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--glass-border);
background-color: rgba(0, 0, 0, 0.2);
color: white;
outline: none;
}
input:focus {
border-color: var(--accent-color);
}
/* Layout */
header {
background: var(--surface-color);
border-bottom: 1px solid var(--glass-border);
padding: 1rem 2rem;
}
.logo {
height: 40px;
margin-right: 1rem;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--glass-border);
}
th {
color: var(--text-muted);
font-weight: 600;
}
tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
width: 90%;
max-width: 500px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

257
frontend/static/js/app.js Normal file
View File

@@ -0,0 +1,257 @@
const API_URL = ""; // Relative path since served by same origin
// State
let token = localStorage.getItem("token");
let currentUser = null;
// DOM Elements
const loginPage = document.getElementById("login-page");
const dashboardLayout = document.getElementById("dashboard-layout");
const loginForm = document.getElementById("login-form");
const loginError = document.getElementById("login-error");
const logoutBtn = document.getElementById("logout-btn");
const usersSection = document.getElementById("users-section");
const appsSection = document.getElementById("apps-section");
const usersTableBody = document.querySelector("#users-table tbody");
const appsTableBody = document.querySelector("#apps-table tbody");
// Init
function init() {
if (token) {
showDashboard();
} else {
showLogin();
}
}
// Navigation
function showLogin() {
loginPage.classList.remove("hidden");
dashboardLayout.classList.add("hidden");
}
function showDashboard() {
loginPage.classList.add("hidden");
dashboardLayout.classList.remove("hidden");
loadUsers();
}
function showSection(section) {
if (section === 'users') {
usersSection.classList.remove("hidden");
appsSection.classList.add("hidden");
loadUsers();
} else {
usersSection.classList.add("hidden");
appsSection.classList.remove("hidden");
loadApps();
}
}
// Auth
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try {
const formData = new FormData();
formData.append("username", username);
formData.append("password", password);
const res = await fetch(`${API_URL}/token`, {
method: "POST",
body: formData
});
if (!res.ok) throw new Error("Invalid credentials");
const data = await res.json();
token = data.access_token;
localStorage.setItem("token", token);
loginError.classList.add("hidden");
showDashboard();
} catch (err) {
loginError.textContent = err.message;
loginError.classList.remove("hidden");
}
});
logoutBtn.addEventListener("click", () => {
token = null;
localStorage.removeItem("token");
showLogin();
});
// API Helpers
async function authFetch(url, options = {}) {
const headers = {
...options.headers,
"Authorization": `Bearer ${token}`
};
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
logoutBtn.click();
throw new Error("Unauthorized");
}
return res;
}
// Users
async function loadUsers() {
const res = await authFetch(`${API_URL}/users/`);
const users = await res.json();
usersTableBody.innerHTML = users.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.username}</td>
<td>${user.email}</td>
<td>${user.is_admin ? "Yes" : "No"}</td>
<td>${user.is_active ? "Yes" : "No"}</td>
<td>
<button class="btn btn-secondary" onclick='editUser(${JSON.stringify(user)})'>Edit</button>
<button class="btn btn-primary" onclick='assignAppModal(${user.id})'>Assign App</button>
</td>
</tr>
`).join("");
}
// Apps
async function loadApps() {
const res = await authFetch(`${API_URL}/apps/`);
const apps = await res.json();
appsTableBody.innerHTML = apps.map(app => `
<tr>
<td>${app.id}</td>
<td>${app.name}</td>
<td>${app.url}</td>
<td><code>${app.api_key}</code></td>
</tr>
`).join("");
}
// Modals
function openModal(id) {
document.getElementById(id).classList.remove("hidden");
}
function closeModal(id) {
document.getElementById(id).classList.add("hidden");
}
// User Form
const userForm = document.getElementById("user-form");
userForm.addEventListener("submit", async (e) => {
e.preventDefault();
const id = document.getElementById("user-id").value;
const username = document.getElementById("user-username").value;
const email = document.getElementById("user-email").value;
const password = document.getElementById("user-password").value;
const isAdmin = document.getElementById("user-is-admin").checked;
const isActive = document.getElementById("user-is-active").checked;
const data = { username, email, is_admin: isAdmin, is_active: isActive };
if (password) data.password = password;
try {
let res;
if (id) {
res = await authFetch(`${API_URL}/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
} else {
if (!password) return alert("Password required for new user");
res = await authFetch(`${API_URL}/users/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail);
}
closeModal("user-modal");
loadUsers();
} catch (err) {
alert(err.message);
}
});
window.editUser = (user) => {
document.getElementById("user-id").value = user.id;
document.getElementById("user-username").value = user.username;
document.getElementById("user-email").value = user.email;
document.getElementById("user-password").value = "";
document.getElementById("user-is-admin").checked = user.is_admin;
document.getElementById("user-is-active").checked = user.is_active;
openModal("user-modal");
};
// App Form
const appForm = document.getElementById("app-form");
appForm.addEventListener("submit", async (e) => {
e.preventDefault();
const name = document.getElementById("app-name").value;
const url = document.getElementById("app-url").value;
try {
const res = await authFetch(`${API_URL}/apps/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, url })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail);
}
closeModal("app-modal");
loadApps();
} catch (err) {
alert(err.message);
}
});
// Assign App
window.assignAppModal = async (userId) => {
document.getElementById("assign-user-id").value = userId;
// Load apps for select
const res = await authFetch(`${API_URL}/apps/`);
const apps = await res.json();
const select = document.getElementById("assign-app-select");
select.innerHTML = '<option value="">Select Application</option>' +
apps.map(app => `<option value="${app.id}">${app.name}</option>`).join("");
openModal("assign-modal");
};
const assignForm = document.getElementById("assign-form");
assignForm.addEventListener("submit", async (e) => {
e.preventDefault();
const userId = document.getElementById("assign-user-id").value;
const appId = document.getElementById("assign-app-select").value;
try {
const res = await authFetch(`${API_URL}/users/${userId}/assign/${appId}`, {
method: "POST"
});
if (!res.ok) throw new Error("Failed to assign");
closeModal("assign-modal");
alert("Assigned successfully");
} catch (err) {
alert(err.message);
}
});
// Start
init();

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi
uvicorn
sqlalchemy
pydantic
passlib[bcrypt]
python-multipart
python-jose[cryptography]
aiosmtplib
jinja2