first commit

This commit is contained in:
Stefano Manfredi
2025-12-01 14:58:40 +00:00
commit 2866bff217
28 changed files with 5515 additions and 0 deletions

21
registration/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM python:3.11-alpine
WORKDIR /app
# Install LDAP tools and dependencies
RUN apk add --no-cache openldap-clients
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app.py .
COPY templates/ templates/
# Create data directory for SQLite database
RUN mkdir -p /data && chmod 777 /data
EXPOSE 5000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]

572
registration/app.py Normal file
View File

@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""
User Self-Provisioning Service for org-stack
Provides a public registration form and admin approval dashboard.
Approved users are automatically created in lldap.
Workflow:
- Public registration form at /
- Admin dashboard at /admin (protected by Authelia forward-auth)
- Pending requests → Approve (creates in lldap) or Reject → Audit log
- lldap is the single source of truth for all active users
"""
import os
import sqlite3
import secrets
import string
import subprocess
from datetime import datetime
from contextlib import contextmanager
from typing import Optional
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
app = FastAPI(title="User Registration Service")
templates = Jinja2Templates(directory="templates")
# Configuration from environment
DATABASE = os.environ.get('DATABASE_PATH', '/data/registrations.db')
LLDAP_ADMIN_USER = os.environ.get('LLDAP_ADMIN_USER', 'admin')
LLDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', 'dc=example,dc=com')
LDAP_HOST = 'ldap://lldap:3890'
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', '')
SMTP_ENABLED = os.environ.get('SMTP_ENABLED', 'false').lower() == 'true'
SMTP_HOST = os.environ.get('SMTP_HOST', 'localhost')
SMTP_PORT = int(os.environ.get('SMTP_PORT', '587'))
SMTP_USER = os.environ.get('SMTP_USER', '')
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
SMTP_FROM = os.environ.get('SMTP_FROM', ADMIN_EMAIL)
SMTP_USE_TLS = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true'
EMAIL_LOG_FILE = '/data/emails.log'
# =============================================================================
# Database Functions
# =============================================================================
@contextmanager
def get_db():
"""Context manager for database connections"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
try:
init_db(conn)
yield conn
finally:
conn.close()
def init_db(db: sqlite3.Connection):
"""Initialize database tables"""
# Pending registration requests
db.execute('''
CREATE TABLE IF NOT EXISTS registration_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT,
user_agent TEXT
)
''')
# Historical audit log of all approved/rejected requests
db.execute('''
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
email TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
reason TEXT,
action TEXT NOT NULL,
performed_by TEXT,
rejection_reason TEXT,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP,
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
db.commit()
# =============================================================================
# LDAP Security - Injection Prevention
# =============================================================================
def escape_ldap_dn(value: str) -> str:
"""Escape LDAP DN special characters per RFC 4514"""
value = value.replace('\\', '\\\\')
replacements = {',': '\\,', '#': '\\#', '+': '\\+', '<': '\\<',
'>': '\\>', ';': '\\;', '"': '\\"', '=': '\\='}
for char, escaped in replacements.items():
value = value.replace(char, escaped)
if value.startswith(' '):
value = '\\' + value
if value.endswith(' '):
value = value[:-1] + '\\ '
return value
def validate_username_strict(username: str) -> bool:
"""Validate username: 2-64 chars, lowercase alphanumeric + underscore, must start with letter"""
if not username or len(username) < 2 or len(username) > 64:
return False
if not username.replace('_', '').isalnum() or not username.islower():
return False
if not username[0].isalpha():
return False
return True
def validate_email_basic(email: str) -> bool:
"""Basic email validation"""
if not email or '@' not in email:
return False
if len(email) > 255:
return False
# Basic format check
parts = email.split('@')
if len(parts) != 2:
return False
local, domain = parts
if not local or not domain or '.' not in domain:
return False
return True
# =============================================================================
# lldap LDAP Integration
# =============================================================================
def get_lldap_admin_password() -> str:
"""Read lldap admin password from secret file"""
secret_file = '/secrets-lldap/LDAP_USER_PASS'
if os.path.exists(secret_file):
with open(secret_file, 'r') as f:
return f.read().strip()
return os.environ.get('LLDAP_ADMIN_PASSWORD', '')
async def create_lldap_user(username: str, email: str, first_name: str, last_name: str) -> tuple[bool, str, str]:
"""Create user in lldap via LDAP. Returns (success, password, error)"""
try:
# Validate inputs
if not validate_username_strict(username):
print(f'[SECURITY] Invalid username: {username}')
return False, '', 'Invalid username format'
if not validate_email_basic(email):
return False, '', 'Invalid email format'
if not first_name or len(first_name) > 100 or not last_name or len(last_name) > 100:
return False, '', 'Invalid name fields'
# Generate random password
password = ''.join(secrets.choice(
string.ascii_letters + string.digits + string.punctuation
) for _ in range(20))
admin_password = get_lldap_admin_password()
# Escape LDAP DN components
user_dn = f'uid={escape_ldap_dn(username)},ou=people,{LLDAP_BASE_DN}'
admin_dn = f'uid={escape_ldap_dn(LLDAP_ADMIN_USER)},ou=people,{LLDAP_BASE_DN}'
# Create LDIF for new user
ldif_content = f'''dn: {user_dn}
objectClass: person
objectClass: inetOrgPerson
uid: {escape_ldap_dn(username)}
cn: {escape_ldap_dn(first_name)} {escape_ldap_dn(last_name)}
sn: {escape_ldap_dn(last_name)}
givenName: {escape_ldap_dn(first_name)}
mail: {escape_ldap_dn(email)}
'''
# Step 1: Create user with ldapadd
try:
result = subprocess.run(
['ldapadd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-x'],
input=ldif_content, capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
print(f'[ERROR] ldapadd failed: {result.stderr}')
return False, '', f'Failed to create user: {result.stderr}'
print(f'[SUCCESS] User {username} created in lldap')
except subprocess.TimeoutExpired:
return False, '', 'LDAP operation timed out'
except Exception as e:
return False, '', f'Failed to create user: {str(e)}'
# Step 2: Set password with ldappasswd
try:
result = subprocess.run(
['ldappasswd', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, '-s', password, user_dn],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
print(f'[ERROR] ldappasswd failed: {result.stderr}')
# Cleanup: delete user entry
subprocess.run(['ldapdelete', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password, user_dn],
capture_output=True, timeout=10)
return False, '', f'Failed to set password: {result.stderr}'
print(f'[SUCCESS] Password set for user {username}')
return True, password, ''
except subprocess.TimeoutExpired:
return False, '', 'LDAP operation timed out'
except Exception as e:
return False, '', f'Failed to set password: {str(e)}'
except Exception as e:
return False, '', str(e)
# =============================================================================
# LDAP Validation Functions
# =============================================================================
def check_ldap_user_exists(username: str = None, email: str = None) -> tuple[bool, str]:
"""
Check if username or email already exists in lldap.
Returns (exists: bool, error_message: str)
"""
try:
admin_password = open('/secrets-lldap/LDAP_USER_PASS').read().strip()
admin_dn = f'uid={LLDAP_ADMIN_USER},ou=people,{LLDAP_BASE_DN}'
# Check username exists
if username:
result = subprocess.run(
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(uid={escape_ldap_dn(username)})', 'dn'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return True, f'Username "{username}" is already taken'
# Check email exists
if email:
result = subprocess.run(
['ldapsearch', '-x', '-LLL', '-H', LDAP_HOST, '-D', admin_dn, '-w', admin_password,
'-b', f'ou=people,{LLDAP_BASE_DN}', f'(mail={escape_ldap_dn(email)})', 'dn'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return True, f'Email "{email}" is already registered'
return False, ''
except subprocess.TimeoutExpired:
print('[ERROR] LDAP search timed out')
return False, '' # Fail open - allow registration if LDAP is slow
except Exception as e:
print(f'[ERROR] Failed to check LDAP: {str(e)}')
return False, '' # Fail open - allow registration if LDAP check fails
# =============================================================================
# Email Notifications
# =============================================================================
def log_email_to_file(to: str, subject: str, body: str):
"""Log email to file when SMTP is disabled"""
timestamp = datetime.now().isoformat()
with open(EMAIL_LOG_FILE, 'a') as f:
f.write(f'\n{"="*80}\n')
f.write(f'Timestamp: {timestamp}\n')
f.write(f'To: {to}\n')
f.write(f'Subject: {subject}\n')
f.write(f'Body:\n{body}\n')
f.write(f'{"="*80}\n')
def send_email(to: str, subject: str, body: str) -> bool:
"""Send email notification via SMTP or log to file"""
if not SMTP_ENABLED:
log_email_to_file(to, subject, body)
print(f'[EMAIL] Logged to {EMAIL_LOG_FILE}: {to} - {subject}')
return True
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart()
msg['From'] = SMTP_FROM
msg['To'] = to
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
if SMTP_USE_TLS:
server.starttls()
if SMTP_USER and SMTP_PASSWORD:
server.login(SMTP_USER, SMTP_PASSWORD)
server.send_message(msg)
server.quit()
print(f'[EMAIL] Sent to {to}: {subject}')
return True
except Exception as e:
print(f'[ERROR] Failed to send email to {to}: {e}')
# Fallback: log to file
log_email_to_file(to, subject, body)
return False
def notify_admin_new_request(username: str, email: str, reason: str):
"""Send email to admin about new registration request"""
subject = f'New registration request: {username}'
body = f'''
A new user has requested an account:
Username: {username}
Email: {email}
Reason: {reason}
Please review and approve/reject at the admin dashboard.
'''
send_email(ADMIN_EMAIL, subject, body)
def notify_user_approved(email: str, username: str, password: str):
"""Send email to user with their credentials after approval"""
subject = 'Account approved'
body = f'''
Your account request has been approved!
Username: {username}
Temporary Password: {password}
Please login and change your password immediately after your first login.
For security, this password is randomly generated and should be changed.
'''
send_email(email, subject, body)
def notify_user_rejected(email: str, username: str, reason: str):
"""Send email to user about rejection"""
subject = 'Account request rejected'
body = f'''
Your account request for username '{username}' has been rejected.
Reason: {reason}
If you believe this was an error, please contact the administrator.
'''
send_email(email, subject, body)
# =============================================================================
# Routes
# =============================================================================
@app.get('/', response_class=HTMLResponse)
async def register_form(request: Request, success: Optional[str] = None, error: Optional[str] = None):
"""Public registration form"""
return templates.TemplateResponse(
'register.html',
{'request': request, 'success': success, 'error': error}
)
@app.post('/', response_class=HTMLResponse)
async def register_submit(
request: Request,
username: str = Form(...),
email: str = Form(...),
first_name: str = Form(...),
last_name: str = Form(...),
reason: str = Form(default='')
):
"""Handle registration form submission"""
username = username.strip().lower()
email = email.strip().lower()
first_name = first_name.strip()
last_name = last_name.strip()
reason = reason.strip()
if not all([username, email, first_name, last_name]):
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': 'All fields except reason are required'}
)
if not validate_username_strict(username):
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': 'Username must be 2-64 characters, start with a letter, and contain only lowercase letters, numbers, and underscores'}
)
if not validate_email_basic(email):
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': 'Invalid email address'}
)
if len(first_name) > 100 or len(last_name) > 100:
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': 'Names must be less than 100 characters'}
)
# Check if username or email already exists in lldap
exists, error_msg = check_ldap_user_exists(username=username, email=email)
if exists:
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': error_msg}
)
# Check for pending registration and insert in single transaction
with get_db() as db:
# Check if username or email already has a pending request
existing = db.execute(
'SELECT username, email FROM registration_requests WHERE username = ? OR email = ?',
(username, email)
).fetchone()
if existing:
if existing[0] == username:
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': f'Username "{username}" already has a pending registration request'}
)
else:
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': f'Email "{email}" already has a pending registration request'}
)
# Insert new registration request
try:
db.execute(
'''INSERT INTO registration_requests
(username, email, first_name, last_name, reason, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(username, email, first_name, last_name, reason,
request.client.host, request.headers.get('User-Agent', ''))
)
db.commit()
# Notify admin
if ADMIN_EMAIL:
notify_admin_new_request(username, email, reason)
return RedirectResponse(
url='/?success=Registration request submitted! An administrator will review it shortly.',
status_code=303
)
except sqlite3.IntegrityError:
return templates.TemplateResponse(
'register.html',
{'request': request, 'error': 'Username already exists or is pending approval'}
)
@app.get('/admin', response_class=HTMLResponse)
async def admin_dashboard(request: Request):
"""Admin dashboard for reviewing registration requests"""
admin_user = request.headers.get('Remote-User', 'unknown')
with get_db() as db:
pending = db.execute(
'SELECT * FROM registration_requests ORDER BY created_at DESC'
).fetchall()
audit_log = db.execute(
'SELECT * FROM audit_log ORDER BY reviewed_at DESC LIMIT 50'
).fetchall()
return templates.TemplateResponse(
'admin.html',
{
'request': request,
'pending': pending,
'audit_log': audit_log,
'admin_user': admin_user
}
)
@app.post('/admin/approve/{request_id}')
async def approve_request(request_id: int, request: Request):
"""Approve request: create user in lldap, move to audit log"""
admin_user = request.headers.get('Remote-User', 'unknown')
with get_db() as db:
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
if not req:
raise HTTPException(status_code=404, detail='Request not found')
# Create user in lldap with generated password
success, password, error = await create_lldap_user(
req['username'],
req['email'],
req['first_name'],
req['last_name']
)
if success:
# Move to audit log
db.execute(
'''INSERT INTO audit_log
(username, email, first_name, last_name, reason, action, performed_by,
ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, 'APPROVED', ?, ?, ?, ?)''',
(req['username'], req['email'], req['first_name'], req['last_name'],
req['reason'], admin_user, req['ip_address'], req['user_agent'], req['created_at'])
)
# Remove from pending queue
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
db.commit()
print(f'[SUCCESS] User {req["username"]} approved and created in lldap by {admin_user}')
# Notify user
notify_user_approved(req['email'], req['username'], password)
else:
print(f'[ERROR] Failed to create user {req["username"]}: {error}')
return RedirectResponse(url='/admin', status_code=303)
@app.post('/admin/reject/{request_id}')
async def reject_request(request_id: int, request: Request, reason: str = Form(default='No reason provided')):
"""Reject request: move to audit log with reason"""
admin_user = request.headers.get('Remote-User', 'unknown')
with get_db() as db:
req = db.execute('SELECT * FROM registration_requests WHERE id = ?', (request_id,)).fetchone()
if not req:
raise HTTPException(status_code=404, detail='Request not found')
# Move to audit log
db.execute(
'''INSERT INTO audit_log
(username, email, first_name, last_name, reason, action, performed_by,
rejection_reason, ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, 'REJECTED', ?, ?, ?, ?, ?)''',
(req['username'], req['email'], req['first_name'], req['last_name'],
req['reason'], admin_user, reason, req['ip_address'], req['user_agent'], req['created_at'])
)
# Remove from pending queue
db.execute('DELETE FROM registration_requests WHERE id = ?', (request_id,))
db.commit()
print(f'[INFO] User {req["username"]} rejected by {admin_user}: {reason}')
# Notify user
notify_user_rejected(req['email'], req['username'], reason)
return RedirectResponse(url='/admin', status_code=303)
@app.get('/health')
async def health():
"""Health check endpoint"""
return {'status': 'healthy'}

View File

@@ -0,0 +1,4 @@
fastapi==0.109.0
uvicorn==0.27.0
python-multipart==0.0.6
jinja2==3.1.3

View File

@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - User Registration{% endblock %}
{% block content %}
<div class="header">
<h1>Registration Requests</h1>
<div class="user-info">Logged in as: <strong>{{ admin_user }}</strong></div>
</div>
<p class="info-text">
<strong>Note:</strong> lldap is the single source of truth for user management.
Approve requests to create users in lldap. Manage active users directly in lldap.
</p>
<h2>Pending Requests ({{ pending|length }})</h2>
{% if pending %}
<table>
<thead>
<tr>
<th>Username</th>
<th>Name</th>
<th>Email</th>
<th>Reason</th>
<th>Submitted</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in pending %}
<tr>
<td><strong>{{ req.username }}</strong></td>
<td>{{ req.first_name }} {{ req.last_name }}</td>
<td>{{ req.email }}</td>
<td>{{ req.reason or '-' }}</td>
<td>{{ req.created_at[:19] }}</td>
<td>
<form method="post" action="/admin/approve/{{ req.id }}" style="display: inline; margin-right: 0.5rem;">
<button type="submit" class="btn-success"
onclick="return confirm('Approve this user and create account in lldap?')">
Approve
</button>
</form>
<form method="post" action="/admin/reject/{{ req.id }}" style="display: inline;">
<input type="text" name="reason" placeholder="Rejection reason (optional)"
style="width: 200px; margin-right: 0.5rem;">
<button type="submit" class="btn-danger"
onclick="return confirm('Reject this request?')">
Reject
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No pending requests</div>
{% endif %}
<h2>Audit Log ({{ audit_log|length }} recent)</h2>
<p class="info-text" style="font-size: 0.9rem; color: #666;">
Historical record of all approval and rejection decisions.
To manage active users, use the lldap admin interface.
</p>
{% if audit_log %}
<table>
<thead>
<tr>
<th>Action</th>
<th>Username</th>
<th>Name</th>
<th>Email</th>
<th>Reviewed By</th>
<th>Date</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% for entry in audit_log %}
<tr>
<td>
{% if entry.action == 'APPROVED' %}
<span class="badge badge-success">Approved</span>
{% else %}
<span class="badge badge-danger">Rejected</span>
{% endif %}
</td>
<td><strong>{{ entry.username }}</strong></td>
<td>{{ entry.first_name }} {{ entry.last_name }}</td>
<td>{{ entry.email }}</td>
<td>{{ entry.performed_by }}</td>
<td>{{ entry.reviewed_at[:19] }}</td>
<td>
{% if entry.action == 'REJECTED' %}
{{ entry.rejection_reason or '-' }}
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No audit log entries yet</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}User Registration{% endblock %}</title>
<style>
:root {
--bg-primary: #ffffff;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--primary-color: #0d6efd;
--primary-hover: #0b5ed7;
--success-color: #198754;
--success-hover: #157347;
--danger-color: #dc3545;
--danger-hover: #bb2d3b;
--info-bg: #cfe2ff;
--success-bg: #d1e7dd;
--danger-bg: #f8d7da;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 500;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 1.5rem;
font-weight: 500;
margin: 2rem 0 1rem;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
input, textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
textarea {
resize: vertical;
min-height: 80px;
}
button, .btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 400;
text-decoration: none;
display: inline-block;
transition: background-color 0.15s;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: var(--success-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.alert {
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.alert-success {
background: var(--success-bg);
color: #0f5132;
border: 1px solid #badbcc;
}
.alert-error {
background: var(--danger-bg);
color: #842029;
border: 1px solid #f5c2c7;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
font-size: 0.875rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
background: #f8f9fa;
border-top: 1px solid var(--border-color);
}
tr:hover {
background: #f8f9fa;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.badge-success {
background: #d1e7dd;
color: #0f5132;
}
.badge-danger {
background: #f8d7da;
color: #842029;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.required {
color: var(--danger-color);
}
.help-text {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.user-info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.info-text {
padding: 1rem;
margin-bottom: 1rem;
background: #e7f3ff;
border-left: 4px solid var(--primary-color);
border-radius: 0.25rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
body {
padding: 10px;
}
table {
font-size: 0.75rem;
}
th, td {
padding: 0.5rem;
}
}
</style>
{% block extra_style %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Register - User Registration{% endblock %}
{% block content %}
<h1>Request Account</h1>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="alert alert-info" style="background: var(--info-bg); color: #084298; border: 1px solid #b6d4fe; margin-bottom: 1.5rem;">
<strong>Note:</strong> Your password will be automatically generated and sent to you via email upon approval.
You can change it after your first login.
</div>
<div style="max-width: 600px;">
<form method="post">
<div class="form-group">
<label for="username">Username <span class="required">*</span></label>
<input type="text" id="username" name="username" required pattern="[a-z0-9_]+"
title="Only lowercase letters, numbers, and underscores allowed"
style="text-transform: lowercase;"
oninput="this.value = this.value.toLowerCase()">
<div class="help-text">Only lowercase letters, numbers, and underscores.</div>
</div>
<div class="form-group">
<label for="email">Email <span class="required">*</span></label>
<input type="email" id="email" name="email" required>
<div class="help-text">Your temporary password will be sent to this address upon approval.</div>
</div>
<div class="form-group">
<label for="first_name">First Name <span class="required">*</span></label>
<input type="text" id="first_name" name="first_name" required>
</div>
<div class="form-group">
<label for="last_name">Last Name <span class="required">*</span></label>
<input type="text" id="last_name" name="last_name" required>
</div>
<div class="form-group">
<label for="reason">Reason for Access (Optional)</label>
<textarea id="reason" name="reason" placeholder="Why do you need an account?"></textarea>
<div class="help-text">Optional: Help administrators understand your request.</div>
</div>
<button type="submit" class="btn-primary">Submit Request</button>
</form>
</div>
{% endblock %}