commit fdf1e0e8ed5ec69c42408179ace73f945bf94dea Author: mahmamdouh Date: Sun Jan 25 14:36:01 2026 +0100 sso diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..133eaa4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Nabd.png b/Nabd.png new file mode 100644 index 0000000..c445709 Binary files /dev/null and b/Nabd.png differ diff --git a/backend/auth_utils.py b/backend/auth_utils.py new file mode 100644 index 0000000..5a497e9 --- /dev/null +++ b/backend/auth_utils.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..e6870cf --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d861775 --- /dev/null +++ b/backend/main.py @@ -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() diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..b501ccf --- /dev/null +++ b/backend/models.py @@ -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") diff --git a/backend/routers/apps.py b/backend/routers/apps.py new file mode 100644 index 0000000..f0dadb4 --- /dev/null +++ b/backend/routers/apps.py @@ -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 diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..04605e5 --- /dev/null +++ b/backend/routers/auth.py @@ -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"} diff --git a/backend/routers/sso.py b/backend/routers/sso.py new file mode 100644 index 0000000..867f964 --- /dev/null +++ b/backend/routers/sso.py @@ -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} diff --git a/backend/routers/users.py b/backend/routers/users.py new file mode 100644 index 0000000..4e9122f --- /dev/null +++ b/backend/routers/users.py @@ -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"} diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..5a07b90 --- /dev/null +++ b/backend/schemas.py @@ -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 diff --git a/backend/services/email.py b/backend/services/email.py new file mode 100644 index 0000000..264c06c --- /dev/null +++ b/backend/services/email.py @@ -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) diff --git a/caddy_config.txt b/caddy_config.txt new file mode 100644 index 0000000..d0c754a --- /dev/null +++ b/caddy_config.txt @@ -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 +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..06297c8 --- /dev/null +++ b/deploy.sh @@ -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 "---------------------------------------------------" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..43d7c3e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/api_reference.md b/docs/api_reference.md new file mode 100644 index 0000000..f31b221 --- /dev/null +++ b/docs/api_reference.md @@ -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 ` + +### `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 ` + +### `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`**. diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 0000000..5105652 --- /dev/null +++ b/docs/user_guide.md @@ -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`. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72892aa --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,150 @@ + + + + + + ASF SSO - Admin Portal + + + + +
+
+ +

Admin Login

+
+ + + +
+ +
+
+ + + + + + + + + + + + + + + + diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css new file mode 100644 index 0000000..b178f2e --- /dev/null +++ b/frontend/static/css/style.css @@ -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; +} diff --git a/frontend/static/img/logo.png b/frontend/static/img/logo.png new file mode 100644 index 0000000..c445709 Binary files /dev/null and b/frontend/static/img/logo.png differ diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js new file mode 100644 index 0000000..6e950c3 --- /dev/null +++ b/frontend/static/js/app.js @@ -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 => ` + + ${user.id} + ${user.username} + ${user.email} + ${user.is_admin ? "Yes" : "No"} + ${user.is_active ? "Yes" : "No"} + + + + + + `).join(""); +} + +// Apps +async function loadApps() { + const res = await authFetch(`${API_URL}/apps/`); + const apps = await res.json(); + appsTableBody.innerHTML = apps.map(app => ` + + ${app.id} + ${app.name} + ${app.url} + ${app.api_key} + + `).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 = '' + + apps.map(app => ``).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(); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3d0567b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +sqlalchemy +pydantic +passlib[bcrypt] +python-multipart +python-jose[cryptography] +aiosmtplib +jinja2