Compare commits

1 Commits

Author SHA1 Message Date
01863c1df0 feature1 2026-01-25 21:02:45 +01:00
10 changed files with 399 additions and 7 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .database import engine, Base, SessionLocal from .database import engine, Base, SessionLocal
from .routers import auth, users, apps, sso from .routers import auth, users, apps, sso, requests
from . import models, auth_utils from . import models, auth_utils
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -25,6 +25,7 @@ app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(sso.router) app.include_router(sso.router)
app.include_router(requests.router)
import os import os

View File

@@ -38,3 +38,23 @@ class UserApplication(Base):
user = relationship("User", back_populates="applications") user = relationship("User", back_populates="applications")
application = relationship("Application", back_populates="users") application = relationship("Application", back_populates="users")
# Association table for AccessRequest and Application
class RequestApplication(Base):
__tablename__ = "request_applications"
id = Column(Integer, primary_key=True, index=True)
request_id = Column(Integer, ForeignKey("access_requests.id"))
application_id = Column(Integer, ForeignKey("applications.id"))
class AccessRequest(Base):
__tablename__ = "access_requests"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, index=True)
email = Column(String, index=True)
hashed_password = Column(String)
status = Column(String, default="pending") # pending, approved, rejected
created_at = Column(DateTime, default=datetime.utcnow)
requested_apps = relationship("Application", secondary="request_applications")

View File

@@ -6,11 +6,10 @@ from .. import database, models, schemas, auth_utils
router = APIRouter( router = APIRouter(
prefix="/apps", prefix="/apps",
tags=["Applications"], tags=["Applications"]
dependencies=[Depends(auth_utils.get_current_admin_user)]
) )
@router.post("/", response_model=schemas.ApplicationOut) @router.post("/", response_model=schemas.ApplicationOut, dependencies=[Depends(auth_utils.get_current_admin_user)])
async def create_application(app: schemas.ApplicationCreate, db: Session = Depends(database.get_db)): 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() db_app = db.query(models.Application).filter(models.Application.name == app.name).first()
if db_app: if db_app:
@@ -27,7 +26,12 @@ async def create_application(app: schemas.ApplicationCreate, db: Session = Depen
db.refresh(db_app) db.refresh(db_app)
return db_app return db_app
@router.get("/", response_model=List[schemas.ApplicationOut]) @router.get("/", response_model=List[schemas.ApplicationOut], dependencies=[Depends(auth_utils.get_current_admin_user)])
async def read_applications(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)): 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() apps = db.query(models.Application).offset(skip).limit(limit).all()
return apps return apps
@router.get("/public", response_model=List[schemas.ApplicationOut])
async def read_public_applications(db: Session = Depends(database.get_db)):
apps = db.query(models.Application).all()
return apps

View File

@@ -0,0 +1,98 @@
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="/requests",
tags=["Access Requests"]
)
@router.post("/", response_model=schemas.AccessRequestOut)
async def create_request(request: schemas.AccessRequestCreate, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
# Check if user already exists
existing_user = db.query(models.User).filter(models.User.username == request.username).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already taken")
# Check if request already exists
existing_req = db.query(models.AccessRequest).filter(models.AccessRequest.username == request.username, models.AccessRequest.status == "pending").first()
if existing_req:
raise HTTPException(status_code=400, detail="Pending request already exists for this username")
hashed_password = auth_utils.get_password_hash(request.password)
db_request = models.AccessRequest(
username=request.username,
email=request.email,
hashed_password=hashed_password,
status="pending"
)
# Add requested apps
for app_id in request.app_ids:
app = db.query(models.Application).filter(models.Application.id == app_id).first()
if app:
db_request.requested_apps.append(app)
db.add(db_request)
db.commit()
db.refresh(db_request)
# Send email to user (Received)
background_tasks.add_task(email.send_request_received_email, request.email, request.username)
# Send email to admin (New Request)
background_tasks.add_task(email.send_admin_notification_email, request.username)
return db_request
@router.get("/", response_model=List[schemas.AccessRequestOut], dependencies=[Depends(auth_utils.get_current_admin_user)])
async def read_requests(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
requests = db.query(models.AccessRequest).filter(models.AccessRequest.status == "pending").offset(skip).limit(limit).all()
return requests
@router.post("/{request_id}/approve", dependencies=[Depends(auth_utils.get_current_admin_user)])
async def approve_request(request_id: int, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
req = db.query(models.AccessRequest).filter(models.AccessRequest.id == request_id).first()
if not req:
raise HTTPException(status_code=404, detail="Request not found")
if req.status != "pending":
raise HTTPException(status_code=400, detail="Request already processed")
# Create User
new_user = models.User(
username=req.username,
email=req.email,
hashed_password=req.hashed_password,
is_active=True,
is_admin=False
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Assign Apps
for app in req.requested_apps:
assignment = models.UserApplication(user_id=new_user.id, application_id=app.id)
db.add(assignment)
req.status = "approved"
db.commit()
# Send email to user (Approved)
background_tasks.add_task(email.send_welcome_email, req.email, req.username, "********") # Don't send password again, they know it
return {"message": "Request approved and user created"}
@router.post("/{request_id}/reject", dependencies=[Depends(auth_utils.get_current_admin_user)])
async def reject_request(request_id: int, db: Session = Depends(database.get_db)):
req = db.query(models.AccessRequest).filter(models.AccessRequest.id == request_id).first()
if not req:
raise HTTPException(status_code=404, detail="Request not found")
req.status = "rejected"
db.commit()
return {"message": "Request rejected"}

View File

@@ -69,3 +69,21 @@ class SSOVerifyResponse(BaseModel):
authorized: bool authorized: bool
message: str message: str
user: Optional[UserOut] = None user: Optional[UserOut] = None
# Access Request Schemas
class AccessRequestCreate(BaseModel):
username: str
email: EmailStr
password: str
app_ids: List[int] = []
class AccessRequestOut(BaseModel):
id: int
username: str
email: EmailStr
status: str
created_at: datetime
requested_apps: List[ApplicationOut] = []
class Config:
orm_mode = True

View File

@@ -43,3 +43,25 @@ async def send_welcome_email(to_email: str, username: str, password: str):
ASF Team ASF Team
""" """
await send_email(to_email, subject, body) await send_email(to_email, subject, body)
async def send_request_received_email(to_email: str, username: str):
subject = "Access Request Received"
body = f"""
Hello {username},
We have received your request for access to the ASF SSO platform.
An administrator will review your request shortly.
Regards,
ASF Team
"""
await send_email(to_email, subject, body)
async def send_admin_notification_email(username: str):
to_email = "admin@nabd-co.com" # Default admin email
subject = "New Access Request"
body = f"""
A new access request has been submitted by {username}.
Please log in to the admin portal to review it.
"""
await send_email(to_email, subject, body)

View File

@@ -2,7 +2,7 @@
# Build and start the container # Build and start the container
# Stop and remove old container and volume to ensure clean state # Stop and remove old container and volume to ensure clean state
docker-compose down -v docker-compose down
docker rm -f sso_service || true docker rm -f sso_service || true
# Build and start the container # Build and start the container

View File

@@ -38,6 +38,7 @@
<div class="flex gap-4 mb-8"> <div class="flex gap-4 mb-8">
<button class="btn btn-primary" onclick="showSection('users')">Users</button> <button class="btn btn-primary" onclick="showSection('users')">Users</button>
<button class="btn btn-secondary" onclick="showSection('apps')">Applications</button> <button class="btn btn-secondary" onclick="showSection('apps')">Applications</button>
<button class="btn btn-secondary" onclick="showSection('requests')">Requests</button>
</div> </div>
<!-- Users Section --> <!-- Users Section -->
@@ -88,6 +89,31 @@
</table> </table>
</div> </div>
</section> </section>
<!-- Requests Section -->
<section id="requests-section" class="hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl">Access Requests</h2>
<button class="btn btn-primary" onclick="loadRequests()">Refresh</button>
</div>
<div class="card">
<table id="requests-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Requested Apps</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Requests will be populated here -->
</tbody>
</table>
</div>
</section>
</main> </main>
</div> </div>

148
frontend/register.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASF SSO - Request Access</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.checkbox-group {
text-align: left;
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
margin-bottom: 1rem;
}
.checkbox-group label {
display: block;
margin-bottom: 5px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="flex items-center justify-center" style="min-height: 100vh; padding: 2rem;">
<div class="card text-center" style="width: 100%; max-width: 500px;">
<img src="/static/img/logo.png" alt="ASF Logo" class="logo" style="height: 80px; margin-bottom: 2rem;">
<h2 class="text-2xl mb-4">Request Access</h2>
<form id="register-form">
<input type="text" id="reg-username" placeholder="Username" required>
<input type="email" id="reg-email" placeholder="Email" required>
<input type="password" id="reg-password" placeholder="Password" required>
<h3 class="text-lg mb-2" style="text-align: left;">Select Applications</h3>
<div id="apps-list" class="checkbox-group">
<p>Loading applications...</p>
</div>
<button type="submit" class="btn btn-primary w-full">Submit Request</button>
</form>
<p id="reg-message" class="mt-4 hidden"></p>
<p class="mt-4">
<a href="/" class="text-accent">Back to Login</a>
</p>
</div>
</div>
<script>
const API_URL = "";
// Load Apps
async function loadApps() {
try {
// We can use the public /sso/verify endpoint? No, we need a public apps list or just try to fetch apps if allowed?
// Actually, listing apps usually requires auth.
// BUT, for registration, we need to know what to ask for.
// Let's assume we can fetch apps. If not, we might need a public endpoint for apps.
// Wait, the previous implementation of /apps/ required auth?
// Let's check backend/routers/apps.py.
// If /apps/ is protected, we need to make a public one or just hardcode for now?
// Better: Modify apps router to allow listing? Or just try.
// If it fails, we might need to fix it.
// Let's try to fetch. If 401, we have a problem.
// Assuming for now we might need to make a public endpoint or use a specific one.
// Let's check backend/routers/apps.py first.
const res = await fetch(`${API_URL}/apps/public`); // We might need to create this
if (!res.ok) throw new Error("Could not load applications");
const apps = await res.json();
const container = document.getElementById("apps-list");
if (apps.length === 0) {
container.innerHTML = "<p>No applications available.</p>";
return;
}
container.innerHTML = apps.map(app => `
<label>
<input type="checkbox" name="app" value="${app.id}"> ${app.name}
</label>
`).join("");
} catch (err) {
console.error(err);
document.getElementById("apps-list").innerHTML = "<p>Error loading applications.</p>";
}
}
// Submit
document.getElementById("register-form").addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("reg-username").value;
const email = document.getElementById("reg-email").value;
const password = document.getElementById("reg-password").value;
const checkboxes = document.querySelectorAll('input[name="app"]:checked');
const appIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
const btn = e.target.querySelector("button");
const msg = document.getElementById("reg-message");
btn.disabled = true;
btn.textContent = "Submitting...";
msg.classList.add("hidden");
try {
const res = await fetch(`${API_URL}/requests/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
email,
password,
app_ids: appIds
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Registration failed");
msg.className = "mt-4 text-success";
msg.textContent = "Request submitted successfully! You will receive an email once approved.";
msg.classList.remove("hidden");
e.target.reset();
} catch (err) {
msg.className = "mt-4 text-accent";
msg.textContent = err.message;
msg.classList.remove("hidden");
} finally {
btn.disabled = false;
btn.textContent = "Submit Request";
}
});
loadApps();
</script>
</body>
</html>

View File

@@ -14,6 +14,8 @@ const usersSection = document.getElementById("users-section");
const appsSection = document.getElementById("apps-section"); const appsSection = document.getElementById("apps-section");
const usersTableBody = document.querySelector("#users-table tbody"); const usersTableBody = document.querySelector("#users-table tbody");
const appsTableBody = document.querySelector("#apps-table tbody"); const appsTableBody = document.querySelector("#apps-table tbody");
const requestsSection = document.getElementById("requests-section");
const requestsTableBody = document.querySelector("#requests-table tbody");
// Init // Init
function init() { function init() {
@@ -40,11 +42,18 @@ function showSection(section) {
if (section === 'users') { if (section === 'users') {
usersSection.classList.remove("hidden"); usersSection.classList.remove("hidden");
appsSection.classList.add("hidden"); appsSection.classList.add("hidden");
requestsSection.classList.add("hidden");
loadUsers(); loadUsers();
} else { } else if (section === 'apps') {
usersSection.classList.add("hidden"); usersSection.classList.add("hidden");
appsSection.classList.remove("hidden"); appsSection.classList.remove("hidden");
requestsSection.classList.add("hidden");
loadApps(); loadApps();
} else {
usersSection.classList.add("hidden");
appsSection.classList.add("hidden");
requestsSection.classList.remove("hidden");
loadRequests();
} }
} }
@@ -156,6 +165,52 @@ async function loadApps() {
`).join(""); `).join("");
} }
// Requests
async function loadRequests() {
const res = await authFetch(`${API_URL}/requests/`);
const requests = await res.json();
requestsTableBody.innerHTML = requests.map(req => {
const appsList = req.requested_apps.map(app =>
`<span class="badge badge-info">${app.name}</span>`
).join(" ");
return `
<tr>
<td>${req.id}</td>
<td>${req.username}</td>
<td>${req.email}</td>
<td>${appsList}</td>
<td>${req.status}</td>
<td>
<button class="btn btn-primary" onclick="approveRequest(${req.id})">Approve</button>
<button class="btn btn-secondary" onclick="rejectRequest(${req.id})">Reject</button>
</td>
</tr>
`}).join("");
}
window.approveRequest = async (id) => {
if (!confirm("Approve this request?")) return;
try {
const res = await authFetch(`${API_URL}/requests/${id}/approve`, { method: "POST" });
if (!res.ok) throw new Error("Failed to approve");
loadRequests();
} catch (err) {
alert(err.message);
}
};
window.rejectRequest = async (id) => {
if (!confirm("Reject this request?")) return;
try {
const res = await authFetch(`${API_URL}/requests/${id}/reject`, { method: "POST" });
if (!res.ok) throw new Error("Failed to reject");
loadRequests();
} catch (err) {
alert(err.message);
}
};
// Modals // Modals
function openModal(id) { function openModal(id) {
document.getElementById(id).classList.remove("hidden"); document.getElementById(id).classList.remove("hidden");