sso
This commit is contained in:
13
Dockerfile
Normal file
13
Dockerfile
Normal 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"]
|
||||
56
backend/auth_utils.py
Normal file
56
backend/auth_utils.py
Normal 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
19
backend/database.py
Normal 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
53
backend/main.py
Normal 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
40
backend/models.py
Normal 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
33
backend/routers/apps.py
Normal 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
22
backend/routers/auth.py
Normal 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
31
backend/routers/sso.py
Normal 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
78
backend/routers/users.py
Normal 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
63
backend/schemas.py
Normal 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
45
backend/services/email.py
Normal 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
7
caddy_config.txt
Normal 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
15
deploy.sh
Normal 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
25
docker-compose.yml
Normal 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
140
docs/api_reference.md
Normal 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
61
docs/user_guide.md
Normal 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
150
frontend/index.html
Normal 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>
|
||||
152
frontend/static/css/style.css
Normal file
152
frontend/static/css/style.css
Normal 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;
|
||||
}
|
||||
BIN
frontend/static/img/logo.png
Normal file
BIN
frontend/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
257
frontend/static/js/app.js
Normal file
257
frontend/static/js/app.js
Normal 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
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pydantic
|
||||
passlib[bcrypt]
|
||||
python-multipart
|
||||
python-jose[cryptography]
|
||||
aiosmtplib
|
||||
jinja2
|
||||
Reference in New Issue
Block a user