Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b21fdc5cf | |||
| b381195200 | |||
| 80b6de395f | |||
| d56e0647f7 | |||
| 8909834a19 | |||
| 59662ec4d8 | |||
| e0b6c22c1f | |||
| c53472c5d7 |
@@ -1,2 +1,51 @@
|
|||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Deployment files
|
||||||
deployment.yml
|
deployment.yml
|
||||||
run.sh
|
run.sh
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
tailwindcss
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.3.0"
|
".": "1.4.1"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
|
||||||
|
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
|
||||||
|
|
||||||
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
|
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
- **Site Organization**: Organize subnets and devices by site/location
|
- **Site Organization**: Organize subnets and devices by site/location
|
||||||
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
||||||
- **User Management**: Multi-user support with secure password authentication
|
- **User Management**: Multi-user support with secure password authentication
|
||||||
|
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
|
||||||
|
- **REST API**: Full-featured REST API with API key authentication for programmatic access
|
||||||
- **CSV Export**: Export subnet and rack data to CSV files
|
- **CSV Export**: Export subnet and rack data to CSV files
|
||||||
- **Device Statistics**: View device counts by type
|
- **Device Statistics**: View device counts by type
|
||||||
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
|
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
|
||||||
@@ -142,6 +144,61 @@ View all changes and actions in the "Audit Log" section, with filtering by user,
|
|||||||
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
|
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
|
||||||
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
|
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
|
||||||
|
|
||||||
|
### Role-Based Access Control
|
||||||
|
|
||||||
|
The system uses a granular role-based access control (RBAC) system to manage user permissions:
|
||||||
|
|
||||||
|
1. **Default Roles**:
|
||||||
|
- **Admin**: Full access to all features including user and role management
|
||||||
|
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
|
||||||
|
- **View Only**: Read-only access to view pages but cannot make any changes
|
||||||
|
|
||||||
|
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
|
||||||
|
|
||||||
|
3. **Permission Granularity**: Permissions are organized into categories:
|
||||||
|
- View permissions (access to pages)
|
||||||
|
- Device Management (add, edit, delete devices)
|
||||||
|
- Network Management (subnet operations)
|
||||||
|
- Rack Management (rack operations)
|
||||||
|
- DHCP Configuration
|
||||||
|
- Administration (user and role management)
|
||||||
|
|
||||||
|
4. **User Management**: Navigate to the Users page to:
|
||||||
|
- Create and manage users
|
||||||
|
- Assign roles to users
|
||||||
|
- Create custom roles with specific permissions
|
||||||
|
- View and regenerate API keys
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
The application includes a comprehensive REST API for programmatic access:
|
||||||
|
|
||||||
|
1. **Authentication**: All API requests require an API key, which can be provided via:
|
||||||
|
- `X-API-Key` header
|
||||||
|
- `Authorization: Bearer <api_key>` header
|
||||||
|
- `?api_key=<api_key>` query parameter
|
||||||
|
|
||||||
|
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
|
||||||
|
|
||||||
|
3. **Available Endpoints**:
|
||||||
|
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
|
||||||
|
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
|
||||||
|
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
|
||||||
|
- **Device Types**: `GET /api/v1/device-types`
|
||||||
|
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
|
||||||
|
- **Audit Log**: `GET /api/v1/audit`
|
||||||
|
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
|
||||||
|
|
||||||
|
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
|
||||||
|
|
||||||
|
5. **Documentation**: Full API documentation is available in the Help page of the web interface.
|
||||||
|
|
||||||
|
**Example API Request**:
|
||||||
|
```bash
|
||||||
|
curl -H "X-API-Key: your_api_key" \
|
||||||
|
https://your-server:5000/api/v1/devices
|
||||||
|
```
|
||||||
|
|
||||||
## Kubernetes Deployment
|
## Kubernetes Deployment
|
||||||
|
|
||||||
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
|
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
|
||||||
@@ -190,6 +247,9 @@ spec:
|
|||||||
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
|
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
|
||||||
- Review audit logs regularly for unauthorized changes
|
- Review audit logs regularly for unauthorized changes
|
||||||
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
|
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
|
||||||
|
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
|
||||||
|
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
|
||||||
|
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
import secrets
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ def verify_password(password, hashed):
|
|||||||
return False
|
return False
|
||||||
return hash_password(password, salt) == hashed
|
return hash_password(password, salt) == hashed
|
||||||
|
|
||||||
|
def generate_api_key():
|
||||||
|
"""Generate a secure API key"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
def get_db_connection(app=None):
|
def get_db_connection(app=None):
|
||||||
if app is None:
|
if app is None:
|
||||||
app = current_app
|
app = current_app
|
||||||
@@ -189,6 +194,11 @@ def init_db(app=None):
|
|||||||
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Add api_key column to User table if it doesn't exist
|
||||||
|
cursor.execute("SHOW COLUMNS FROM User LIKE 'api_key'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
|
||||||
|
|
||||||
# Define all permissions with categories
|
# Define all permissions with categories
|
||||||
permissions = [
|
permissions = [
|
||||||
# View permissions
|
# View permissions
|
||||||
@@ -333,9 +343,17 @@ def init_db(app=None):
|
|||||||
# This ensures existing users maintain admin access
|
# This ensures existing users maintain admin access
|
||||||
cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,))
|
cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,))
|
||||||
|
|
||||||
|
# Generate API keys for users that don't have one
|
||||||
|
cursor.execute('SELECT id FROM User WHERE api_key IS NULL')
|
||||||
|
users_without_api_key = cursor.fetchall()
|
||||||
|
for (user_id,) in users_without_api_key:
|
||||||
|
api_key = generate_api_key()
|
||||||
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (api_key, user_id))
|
||||||
|
|
||||||
cursor.execute('SELECT COUNT(*) FROM User')
|
cursor.execute('SELECT COUNT(*) FROM User')
|
||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
cursor.execute('''INSERT INTO User (name, email, password, role_id) VALUES (%s, %s, %s, %s)''',
|
api_key = generate_api_key()
|
||||||
('admin', 'admin@example.com', hash_password('password'), admin_role_id))
|
cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
|
||||||
|
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session, abort
|
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session, abort, jsonify
|
||||||
from db import init_db, hash_password, get_db_connection, verify_password
|
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
|
||||||
from ipaddress import ip_network
|
from ipaddress import ip_network
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import os
|
import os
|
||||||
@@ -65,6 +65,63 @@ def permission_required(permission_name):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def get_user_from_api_key(api_key):
|
||||||
|
"""Get user from API key"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT id, name, email, role_id FROM User WHERE api_key = %s', (api_key,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
return {
|
||||||
|
'id': result[0],
|
||||||
|
'name': result[1],
|
||||||
|
'email': result[2],
|
||||||
|
'role_id': result[3]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def api_auth_required(f):
|
||||||
|
"""Decorator for API authentication using API key"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
api_key = None
|
||||||
|
# Check for API key in header
|
||||||
|
if 'X-API-Key' in request.headers:
|
||||||
|
api_key = request.headers['X-API-Key']
|
||||||
|
# Check for API key in query parameter
|
||||||
|
elif 'api_key' in request.args:
|
||||||
|
api_key = request.args.get('api_key')
|
||||||
|
# Check for API key in Authorization header (Bearer token)
|
||||||
|
elif 'Authorization' in request.headers:
|
||||||
|
auth_header = request.headers['Authorization']
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
api_key = auth_header[7:]
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({'error': 'API key required'}), 401
|
||||||
|
|
||||||
|
user = get_user_from_api_key(api_key)
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Invalid API key'}), 401
|
||||||
|
|
||||||
|
# Store user info in request context
|
||||||
|
request.api_user = user
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def api_permission_required(permission_name):
|
||||||
|
"""Decorator to require a specific permission for API endpoints"""
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
@api_auth_required
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not has_permission(permission_name, user_id=request.api_user['id']):
|
||||||
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
return decorator
|
||||||
|
|
||||||
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
|
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
|
||||||
import datetime
|
import datetime
|
||||||
close_conn = False
|
close_conn = False
|
||||||
@@ -456,9 +513,11 @@ def register_routes(app):
|
|||||||
password = hash_password(request.form['password'])
|
password = hash_password(request.form['password'])
|
||||||
role_id = request.form.get('role_id')
|
role_id = request.form.get('role_id')
|
||||||
if role_id:
|
if role_id:
|
||||||
cursor.execute('INSERT INTO User (name, email, password, role_id) VALUES (%s, %s, %s, %s)', (name, email, password, role_id))
|
api_key = generate_api_key()
|
||||||
|
cursor.execute('INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)', (name, email, password, role_id, api_key))
|
||||||
else:
|
else:
|
||||||
cursor.execute('INSERT INTO User (name, email, password) VALUES (%s, %s, %s)', (name, email, password))
|
api_key = generate_api_key()
|
||||||
|
cursor.execute('INSERT INTO User (name, email, password, api_key) VALUES (%s, %s, %s, %s)', (name, email, password, api_key))
|
||||||
logging.info(f"User {user_name} added user '{name}' ({email}).")
|
logging.info(f"User {user_name} added user '{name}' ({email}).")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
elif action == 'edit_user':
|
elif action == 'edit_user':
|
||||||
@@ -560,10 +619,19 @@ def register_routes(app):
|
|||||||
cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,))
|
cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logging.info(f"User {user_name} deleted role '{role_name}'.")
|
logging.info(f"User {user_name} deleted role '{role_name}'.")
|
||||||
|
elif action == 'regenerate_api_key':
|
||||||
|
if not has_permission('manage_users', conn=conn):
|
||||||
|
error = 'You do not have permission to regenerate API keys.'
|
||||||
|
else:
|
||||||
|
user_id = request.form['user_id']
|
||||||
|
new_api_key = generate_api_key()
|
||||||
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
|
||||||
|
conn.commit()
|
||||||
|
logging.info(f"User {user_name} regenerated API key for user {user_id}.")
|
||||||
|
|
||||||
# Get users with their roles
|
# Get users with their roles and API keys
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name
|
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name, u.api_key
|
||||||
FROM User u
|
FROM User u
|
||||||
LEFT JOIN Role r ON u.role_id = r.id
|
LEFT JOIN Role r ON u.role_id = r.id
|
||||||
ORDER BY u.name
|
ORDER BY u.name
|
||||||
@@ -1188,6 +1256,539 @@ def register_routes(app):
|
|||||||
def help():
|
def help():
|
||||||
return render_with_user('help.html')
|
return render_with_user('help.html')
|
||||||
|
|
||||||
|
# ========== API ROUTES ==========
|
||||||
|
|
||||||
|
@app.route('/api/v1/info', methods=['GET'])
|
||||||
|
@api_auth_required
|
||||||
|
def api_info():
|
||||||
|
"""Get API information and authenticated user info"""
|
||||||
|
return jsonify({
|
||||||
|
'api_version': '1.0',
|
||||||
|
'user': {
|
||||||
|
'id': request.api_user['id'],
|
||||||
|
'name': request.api_user['name'],
|
||||||
|
'email': request.api_user['email']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Devices API
|
||||||
|
@app.route('/api/v1/devices', methods=['GET'])
|
||||||
|
@api_permission_required('view_devices')
|
||||||
|
def api_devices():
|
||||||
|
"""Get all devices"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
||||||
|
FROM Device d
|
||||||
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
||||||
|
ORDER BY d.name
|
||||||
|
''')
|
||||||
|
devices = cursor.fetchall()
|
||||||
|
for device in devices:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
|
||||||
|
FROM DeviceIPAddress dia
|
||||||
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
||||||
|
JOIN Subnet s ON ip.subnet_id = s.id
|
||||||
|
WHERE dia.device_id = %s
|
||||||
|
''', (device['id'],))
|
||||||
|
device['ip_addresses'] = cursor.fetchall()
|
||||||
|
return jsonify({'devices': devices})
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices/<int:device_id>', methods=['GET'])
|
||||||
|
@api_permission_required('view_device')
|
||||||
|
def api_device(device_id):
|
||||||
|
"""Get a specific device"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
|
||||||
|
FROM Device d
|
||||||
|
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
|
||||||
|
WHERE d.id = %s
|
||||||
|
''', (device_id,))
|
||||||
|
device = cursor.fetchone()
|
||||||
|
if not device:
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
|
||||||
|
FROM DeviceIPAddress dia
|
||||||
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
||||||
|
JOIN Subnet s ON ip.subnet_id = s.id
|
||||||
|
WHERE dia.device_id = %s
|
||||||
|
''', (device_id,))
|
||||||
|
device['ip_addresses'] = cursor.fetchall()
|
||||||
|
return jsonify(device)
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices', methods=['POST'])
|
||||||
|
@api_permission_required('add_device')
|
||||||
|
def api_add_device():
|
||||||
|
"""Create a new device"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'name' not in data:
|
||||||
|
return jsonify({'error': 'Device name is required'}), 400
|
||||||
|
|
||||||
|
name = data['name']
|
||||||
|
description = data.get('description', '')
|
||||||
|
device_type_id = data.get('device_type_id', 1)
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('INSERT INTO Device (name, description, device_type_id) VALUES (%s, %s, %s)',
|
||||||
|
(name, description, device_type_id))
|
||||||
|
device_id = cursor.lastrowid
|
||||||
|
add_audit_log(request.api_user['id'], 'add_device', f"Added device {name}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'id': device_id, 'name': name, 'description': description, 'device_type_id': device_type_id}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices/<int:device_id>', methods=['PUT'])
|
||||||
|
@api_permission_required('edit_device')
|
||||||
|
def api_update_device(device_id):
|
||||||
|
"""Update a device"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Request body is required'}), 400
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
if 'name' in data:
|
||||||
|
updates.append('name = %s')
|
||||||
|
values.append(data['name'])
|
||||||
|
if 'description' in data:
|
||||||
|
updates.append('description = %s')
|
||||||
|
values.append(data['description'])
|
||||||
|
if 'device_type_id' in data:
|
||||||
|
updates.append('device_type_id = %s')
|
||||||
|
values.append(data['device_type_id'])
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return jsonify({'error': 'No fields to update'}), 400
|
||||||
|
|
||||||
|
values.append(device_id)
|
||||||
|
cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values)
|
||||||
|
add_audit_log(request.api_user['id'], 'edit_device', f"Updated device {device_id}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Device updated successfully'})
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices/<int:device_id>', methods=['DELETE'])
|
||||||
|
@api_permission_required('delete_device')
|
||||||
|
def api_delete_device(device_id):
|
||||||
|
"""Delete a device"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
||||||
|
device = cursor.fetchone()
|
||||||
|
if not device:
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
||||||
|
add_audit_log(request.api_user['id'], 'delete_device', f"Deleted device {device[0]}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Device deleted successfully'})
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices/<int:device_id>/ips', methods=['POST'])
|
||||||
|
@api_permission_required('add_device_ip')
|
||||||
|
def api_add_device_ip(device_id):
|
||||||
|
"""Add an IP address to a device"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'ip_id' not in data:
|
||||||
|
return jsonify({'error': 'ip_id is required'}), 400
|
||||||
|
|
||||||
|
ip_id = data['ip_id']
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT id FROM Device WHERE id = %s', (device_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
cursor.execute('SELECT id FROM IPAddress WHERE id = %s', (ip_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return jsonify({'error': 'IP address not found'}), 404
|
||||||
|
cursor.execute('SELECT id FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
||||||
|
if cursor.fetchone():
|
||||||
|
return jsonify({'error': 'IP address already assigned to this device'}), 400
|
||||||
|
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
||||||
|
add_audit_log(request.api_user['id'], 'add_device_ip', f"Added IP to device {device_id}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'IP address added to device successfully'}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
|
||||||
|
@api_permission_required('remove_device_ip')
|
||||||
|
def api_remove_device_ip(device_id, ip_id):
|
||||||
|
"""Remove an IP address from a device"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return jsonify({'error': 'IP address not found on device'}), 404
|
||||||
|
add_audit_log(request.api_user['id'], 'remove_device_ip', f"Removed IP from device {device_id}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'IP address removed from device successfully'})
|
||||||
|
|
||||||
|
# Subnets API
|
||||||
|
@app.route('/api/v1/subnets', methods=['GET'])
|
||||||
|
@api_permission_required('view_subnet')
|
||||||
|
def api_subnets():
|
||||||
|
"""Get all subnets"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
|
||||||
|
subnets = cursor.fetchall()
|
||||||
|
for subnet in subnets:
|
||||||
|
cursor.execute('SELECT COUNT(*) as total, COUNT(CASE WHEN hostname IS NOT NULL THEN 1 END) as used FROM IPAddress WHERE subnet_id = %s', (subnet['id'],))
|
||||||
|
stats = cursor.fetchone()
|
||||||
|
subnet['total_ips'] = stats['total']
|
||||||
|
subnet['used_ips'] = stats['used']
|
||||||
|
subnet['available_ips'] = stats['total'] - stats['used']
|
||||||
|
return jsonify({'subnets': subnets})
|
||||||
|
|
||||||
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['GET'])
|
||||||
|
@api_permission_required('view_subnet')
|
||||||
|
def api_subnet(subnet_id):
|
||||||
|
"""Get a specific subnet with IP addresses"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, cidr, site FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
subnet = cursor.fetchone()
|
||||||
|
if not subnet:
|
||||||
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT ip.id, ip.ip, ip.hostname, d.id as device_id, d.name as device_name
|
||||||
|
FROM IPAddress ip
|
||||||
|
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
|
||||||
|
LEFT JOIN Device d ON dia.device_id = d.id
|
||||||
|
WHERE ip.subnet_id = %s
|
||||||
|
ORDER BY ip.ip
|
||||||
|
''', (subnet_id,))
|
||||||
|
subnet['ip_addresses'] = cursor.fetchall()
|
||||||
|
return jsonify(subnet)
|
||||||
|
|
||||||
|
@app.route('/api/v1/subnets', methods=['POST'])
|
||||||
|
@api_permission_required('add_subnet')
|
||||||
|
def api_add_subnet():
|
||||||
|
"""Create a new subnet"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'name' not in data or 'cidr' not in data:
|
||||||
|
return jsonify({'error': 'Name and CIDR are required'}), 400
|
||||||
|
|
||||||
|
name = data['name']
|
||||||
|
cidr = data['cidr']
|
||||||
|
site = data.get('site', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
network = ip_network(cidr, strict=False)
|
||||||
|
if network.prefixlen < 24:
|
||||||
|
return jsonify({'error': 'Subnet must be /24 or smaller'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'error': 'Invalid CIDR format'}), 400
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (%s, %s, %s)', (name, cidr, site))
|
||||||
|
subnet_id = cursor.lastrowid
|
||||||
|
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
|
||||||
|
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
|
||||||
|
add_audit_log(request.api_user['id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'id': subnet_id, 'name': name, 'cidr': cidr, 'site': site}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['PUT'])
|
||||||
|
@api_permission_required('edit_subnet')
|
||||||
|
def api_update_subnet(subnet_id):
|
||||||
|
"""Update a subnet"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Request body is required'}), 400
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
old_subnet = cursor.fetchone()
|
||||||
|
if not old_subnet:
|
||||||
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
if 'name' in data:
|
||||||
|
updates.append('name = %s')
|
||||||
|
values.append(data['name'])
|
||||||
|
if 'cidr' in data:
|
||||||
|
updates.append('cidr = %s')
|
||||||
|
values.append(data['cidr'])
|
||||||
|
if 'site' in data:
|
||||||
|
updates.append('site = %s')
|
||||||
|
values.append(data['site'])
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return jsonify({'error': 'No fields to update'}), 400
|
||||||
|
|
||||||
|
values.append(subnet_id)
|
||||||
|
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
|
||||||
|
add_audit_log(request.api_user['id'], 'edit_subnet', f"Updated subnet {subnet_id}", subnet_id, conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Subnet updated successfully'})
|
||||||
|
|
||||||
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
|
||||||
|
@api_permission_required('delete_subnet')
|
||||||
|
def api_delete_subnet(subnet_id):
|
||||||
|
"""Delete a subnet"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT name FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
subnet = cursor.fetchone()
|
||||||
|
if not subnet:
|
||||||
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
|
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
add_audit_log(request.api_user['id'], 'delete_subnet', f"Deleted subnet {subnet[0]}", subnet_id, conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Subnet deleted successfully'})
|
||||||
|
|
||||||
|
# Racks API
|
||||||
|
@app.route('/api/v1/racks', methods=['GET'])
|
||||||
|
@api_permission_required('view_racks')
|
||||||
|
def api_racks():
|
||||||
|
"""Get all racks"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
|
||||||
|
racks = cursor.fetchall()
|
||||||
|
for rack in racks:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
||||||
|
d.name as device_name
|
||||||
|
FROM RackDevice rd
|
||||||
|
LEFT JOIN Device d ON rd.device_id = d.id
|
||||||
|
WHERE rd.rack_id = %s
|
||||||
|
ORDER BY rd.position_u, rd.side
|
||||||
|
''', (rack['id'],))
|
||||||
|
rack['devices'] = cursor.fetchall()
|
||||||
|
return jsonify({'racks': racks})
|
||||||
|
|
||||||
|
@app.route('/api/v1/racks/<int:rack_id>', methods=['GET'])
|
||||||
|
@api_permission_required('view_rack')
|
||||||
|
def api_rack(rack_id):
|
||||||
|
"""Get a specific rack"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,))
|
||||||
|
rack = cursor.fetchone()
|
||||||
|
if not rack:
|
||||||
|
return jsonify({'error': 'Rack not found'}), 404
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
||||||
|
d.name as device_name
|
||||||
|
FROM RackDevice rd
|
||||||
|
LEFT JOIN Device d ON rd.device_id = d.id
|
||||||
|
WHERE rd.rack_id = %s
|
||||||
|
ORDER BY rd.position_u, rd.side
|
||||||
|
''', (rack_id,))
|
||||||
|
rack['devices'] = cursor.fetchall()
|
||||||
|
return jsonify(rack)
|
||||||
|
|
||||||
|
@app.route('/api/v1/racks', methods=['POST'])
|
||||||
|
@api_permission_required('add_rack')
|
||||||
|
def api_add_rack():
|
||||||
|
"""Create a new rack"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'name' not in data or 'site' not in data or 'height_u' not in data:
|
||||||
|
return jsonify({'error': 'Name, site, and height_u are required'}), 400
|
||||||
|
|
||||||
|
name = data['name']
|
||||||
|
site = data['site']
|
||||||
|
height_u = data['height_u']
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
||||||
|
rack_id = cursor.lastrowid
|
||||||
|
add_audit_log(request.api_user['id'], 'add_rack', f"Added rack {name}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/racks/<int:rack_id>', methods=['DELETE'])
|
||||||
|
@api_permission_required('delete_rack')
|
||||||
|
def api_delete_rack(rack_id):
|
||||||
|
"""Delete a rack"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
||||||
|
rack = cursor.fetchone()
|
||||||
|
if not rack:
|
||||||
|
return jsonify({'error': 'Rack not found'}), 404
|
||||||
|
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
|
||||||
|
add_audit_log(request.api_user['id'], 'delete_rack', f"Deleted rack {rack[0]}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Rack deleted successfully'})
|
||||||
|
|
||||||
|
@app.route('/api/v1/racks/<int:rack_id>/devices', methods=['POST'])
|
||||||
|
@api_permission_required('add_device_to_rack')
|
||||||
|
def api_add_device_to_rack(rack_id):
|
||||||
|
"""Add a device to a rack"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'position_u' not in data or 'side' not in data:
|
||||||
|
return jsonify({'error': 'position_u and side are required'}), 400
|
||||||
|
|
||||||
|
position_u = data['position_u']
|
||||||
|
side = data['side']
|
||||||
|
device_id = data.get('device_id')
|
||||||
|
nonnet_device_name = data.get('nonnet_device_name')
|
||||||
|
|
||||||
|
if not device_id and not nonnet_device_name:
|
||||||
|
return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT id FROM Rack WHERE id = %s', (rack_id,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
return jsonify({'error': 'Rack not found'}), 404
|
||||||
|
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, %s)',
|
||||||
|
(rack_id, device_id, position_u, side, nonnet_device_name))
|
||||||
|
add_audit_log(request.api_user['id'], 'add_device_to_rack', f"Added device to rack {rack_id}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Device added to rack successfully'}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
|
||||||
|
@api_permission_required('remove_device_from_rack')
|
||||||
|
def api_remove_device_from_rack(rack_id, rack_device_id):
|
||||||
|
"""Remove a device from a rack"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id))
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return jsonify({'error': 'Device not found in rack'}), 404
|
||||||
|
add_audit_log(request.api_user['id'], 'remove_device_from_rack', f"Removed device from rack {rack_id}", conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'Device removed from rack successfully'})
|
||||||
|
|
||||||
|
# Device Types API
|
||||||
|
@app.route('/api/v1/device-types', methods=['GET'])
|
||||||
|
@api_permission_required('view_device_types')
|
||||||
|
def api_device_types():
|
||||||
|
"""Get all device types"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name')
|
||||||
|
device_types = cursor.fetchall()
|
||||||
|
return jsonify({'device_types': device_types})
|
||||||
|
|
||||||
|
# DHCP API
|
||||||
|
@app.route('/api/v1/subnets/<int:subnet_id>/dhcp', methods=['GET'])
|
||||||
|
@api_permission_required('view_dhcp')
|
||||||
|
def api_get_dhcp(subnet_id):
|
||||||
|
"""Get DHCP pools for a subnet"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
pools = cursor.fetchall()
|
||||||
|
return jsonify({'pools': pools})
|
||||||
|
|
||||||
|
@app.route('/api/v1/subnets/<int:subnet_id>/dhcp', methods=['POST'])
|
||||||
|
@api_permission_required('configure_dhcp')
|
||||||
|
def api_configure_dhcp(subnet_id):
|
||||||
|
"""Configure DHCP pools for a subnet"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'pools' not in data:
|
||||||
|
return jsonify({'error': 'pools array is required'}), 400
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
for pool in data['pools']:
|
||||||
|
if 'start_ip' not in pool or 'end_ip' not in pool:
|
||||||
|
continue
|
||||||
|
excluded_ips = ','.join(pool.get('excluded_ips', []))
|
||||||
|
cursor.execute('INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)',
|
||||||
|
(subnet_id, pool['start_ip'], pool['end_ip'], excluded_ips))
|
||||||
|
add_audit_log(request.api_user['id'], 'configure_dhcp', f"Configured DHCP for subnet {subnet_id}", subnet_id, conn=conn)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'DHCP pools configured successfully'})
|
||||||
|
|
||||||
|
# Audit Log API
|
||||||
|
@app.route('/api/v1/audit', methods=['GET'])
|
||||||
|
@api_permission_required('view_audit')
|
||||||
|
def api_audit():
|
||||||
|
"""Get audit log entries"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
offset = request.args.get('offset', 0, type=int)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT al.id, al.user_id, u.name as user_name, al.action, al.details, al.subnet_id, al.timestamp
|
||||||
|
FROM AuditLog al
|
||||||
|
LEFT JOIN User u ON al.user_id = u.id
|
||||||
|
ORDER BY al.timestamp DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
''', (limit, offset))
|
||||||
|
logs = cursor.fetchall()
|
||||||
|
return jsonify({'logs': logs})
|
||||||
|
|
||||||
|
# Users API (admin only)
|
||||||
|
@app.route('/api/v1/users', methods=['GET'])
|
||||||
|
@api_permission_required('view_users')
|
||||||
|
def api_users():
|
||||||
|
"""Get all users (admin only)"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name
|
||||||
|
FROM User u
|
||||||
|
LEFT JOIN Role r ON u.role_id = r.id
|
||||||
|
ORDER BY u.name
|
||||||
|
''')
|
||||||
|
users = cursor.fetchall()
|
||||||
|
# Don't return API keys in list
|
||||||
|
for user in users:
|
||||||
|
user.pop('api_key', None)
|
||||||
|
return jsonify({'users': users})
|
||||||
|
|
||||||
|
# Roles API (admin only)
|
||||||
|
@app.route('/api/v1/roles', methods=['GET'])
|
||||||
|
@api_permission_required('view_users')
|
||||||
|
def api_roles():
|
||||||
|
"""Get all roles (admin only)"""
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute('SELECT id, name, description FROM Role ORDER BY name')
|
||||||
|
roles = cursor.fetchall()
|
||||||
|
for role in roles:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT p.id, p.name, p.description, p.category
|
||||||
|
FROM RolePermission rp
|
||||||
|
JOIN Permission p ON rp.permission_id = p.id
|
||||||
|
WHERE rp.role_id = %s
|
||||||
|
''', (role['id'],))
|
||||||
|
role['permissions'] = cursor.fetchall()
|
||||||
|
return jsonify({'roles': roles})
|
||||||
|
|
||||||
def get_current_user_name():
|
def get_current_user_name():
|
||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -1237,3 +1838,16 @@ def register_routes(app):
|
|||||||
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
|
||||||
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
|
||||||
app.add_url_rule('/help', 'help', help)
|
app.add_url_rule('/help', 'help', help)
|
||||||
|
|
||||||
|
# API key regeneration route
|
||||||
|
@app.route('/regenerate_api_key', methods=['POST'])
|
||||||
|
@permission_required('manage_users')
|
||||||
|
def regenerate_api_key():
|
||||||
|
user_id = request.form['user_id']
|
||||||
|
from flask import current_app
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
new_api_key = generate_api_key()
|
||||||
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
|
||||||
|
conn.commit()
|
||||||
|
return redirect(url_for('users'))
|
||||||
|
|||||||
+18
-18
@@ -24,7 +24,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-clipboard-list text-3xl text-blue-500"></i>
|
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">Audit Log</h3>
|
<h3 class="text-lg font-bold">Audit Log</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-users text-3xl text-green-500"></i>
|
<i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">User Management</h3>
|
<h3 class="text-lg font-bold">User Management</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
||||||
{% if can_add_subnet %}
|
{% if can_add_subnet %}
|
||||||
<button onclick="showAddSubnetModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
<button onclick="showAddSubnetModal()" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
||||||
<i class="fas fa-plus mr-2"></i>Add Subnet
|
<i class="fas fa-plus mr-2"></i>Add Subnet
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -60,34 +60,34 @@
|
|||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-600">
|
<tr class="border-b border-gray-600">
|
||||||
<th class="text-left p-3">Name</th>
|
<th class="text-center p-3">Name</th>
|
||||||
<th class="text-left p-3">CIDR</th>
|
<th class="text-center p-3">CIDR</th>
|
||||||
<th class="text-left p-3">Site</th>
|
<th class="text-center p-3">Site</th>
|
||||||
<th class="text-left p-3">Actions</th>
|
<th class="text-center p-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for subnet in subnets %}
|
{% for subnet in subnets %}
|
||||||
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
||||||
<td class="p-3 font-medium">{{ subnet.name }}</td>
|
<td class="p-3 font-medium text-center">{{ subnet.name }}</td>
|
||||||
<td class="p-3 font-mono text-sm">{{ subnet.cidr }}</td>
|
<td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
|
||||||
<td class="p-3">
|
<td class="p-3 text-center">
|
||||||
<span class="px-2 py-1 bg-blue-200 dark:bg-blue-800 rounded text-sm">{{ subnet.site }}</span>
|
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-3 text-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center justify-center space-x-2">
|
||||||
<a href="/subnet/{{ subnet.id }}" class="text-blue-500 hover:text-blue-700" title="View Subnet">
|
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if can_edit_subnet %}
|
{% if can_edit_subnet %}
|
||||||
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-green-500 hover:text-green-700" title="Edit Subnet">
|
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_delete_subnet %}
|
{% if can_delete_subnet %}
|
||||||
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
||||||
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700" title="Delete Subnet">
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
<button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
-4
@@ -75,11 +75,39 @@
|
|||||||
<span class="hidden sm:inline">Prev</span>
|
<span class="hidden sm:inline">Prev</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for p in range(1, total_pages+1) %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
{# Smart pagination logic #}
|
||||||
{% set _ = page_args.update({'page': p}) %}
|
{% set delta = 2 %}
|
||||||
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
{% set start_page = [1, page - delta]|max %}
|
||||||
|
{% set end_page = [total_pages, page + delta]|min %}
|
||||||
|
|
||||||
|
{# Show first page if we're not near the start #}
|
||||||
|
{% if start_page > 1 %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': 1}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
|
||||||
|
{% if start_page > 2 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Show pages around current page #}
|
||||||
|
{% for p in range(start_page, end_page + 1) %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': p}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Show last page if we're not near the end #}
|
||||||
|
{% if end_page < total_pages %}
|
||||||
|
{% if end_page < total_pages - 1 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': total_pages}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
{% set next_args = query_args.copy() %}
|
{% set next_args = query_args.copy() %}
|
||||||
{% set _ = next_args.update({'page': page+1}) %}
|
{% set _ = next_args.update({'page': page+1}) %}
|
||||||
|
|||||||
@@ -80,6 +80,86 @@
|
|||||||
<h3 class="text-xl font-semibold mb-1">Audit & History</h3>
|
<h3 class="text-xl font-semibold mb-1">Audit & History</h3>
|
||||||
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p>
|
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">API Keys</h3>
|
||||||
|
<p>Each user has a unique API key that can be used to authenticate API requests. API keys can be viewed and regenerated from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Keep your API key secure and never share it publicly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">API Documentation</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Authentication</h3>
|
||||||
|
<p>All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
|
||||||
|
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
|
||||||
|
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
|
||||||
|
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
|
||||||
|
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Base URL</h3>
|
||||||
|
<p>All API endpoints are prefixed with <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Available Endpoints</h3>
|
||||||
|
<div class="space-y-3 mt-2">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">Devices</h4>
|
||||||
|
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices</code> - List all devices</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices/{id}</code> - Get device details</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices</code> - Create device</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/devices/{id}</code> - Update device</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}</code> - Delete device</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP from device</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">Subnets</h4>
|
||||||
|
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets</code> - List all subnets</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets</code> - Create subnet</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">Racks</h4>
|
||||||
|
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks</code> - List all racks</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks/{id}</code> - Get rack details</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks</code> - Create rack</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}/devices/{rack_device_id}</code> - Remove device from rack</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">Other</h4>
|
||||||
|
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/info</code> - Get API info and user details</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/device-types</code> - List device types</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP pools</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets/{id}/dhcp</code> - Configure DHCP pools</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/audit</code> - Get audit log</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/users</code> - List users (admin only)</li>
|
||||||
|
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/roles</code> - List roles (admin only)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Permissions</h3>
|
||||||
|
<p>API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">403 Forbidden</code> error.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Response Format</h3>
|
||||||
|
<p>All API responses are in JSON format. Successful requests return <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">200 OK</code> or <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">201 Created</code> with the requested data. Errors return appropriate HTTP status codes with an error message in the response body.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+23
-10
@@ -61,9 +61,9 @@
|
|||||||
<tr class="border-b border-gray-600">
|
<tr class="border-b border-gray-600">
|
||||||
<th class="text-left p-2">Name</th>
|
<th class="text-left p-2">Name</th>
|
||||||
<th class="text-left p-2">Email</th>
|
<th class="text-left p-2">Email</th>
|
||||||
<th class="text-left p-2">Role</th>
|
<th class="text-center p-2">Role</th>
|
||||||
{% if can_manage_users %}
|
{% if can_manage_users %}
|
||||||
<th class="text-left p-2">Actions</th>
|
<th class="text-center p-2">Actions</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -72,18 +72,25 @@
|
|||||||
<tr class="border-b border-gray-600">
|
<tr class="border-b border-gray-600">
|
||||||
<td class="p-2">{{ user[1] }}</td>
|
<td class="p-2">{{ user[1] }}</td>
|
||||||
<td class="p-2">{{ user[2] }}</td>
|
<td class="p-2">{{ user[2] }}</td>
|
||||||
<td class="p-2">
|
<td class="p-2 text-center">
|
||||||
<span class="px-2 py-1 bg-blue-200 dark:bg-blue-800 rounded">{{ user[4] or 'No Role' }}</span>
|
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded">{{ user[4] or 'No Role' }}</span>
|
||||||
</td>
|
</td>
|
||||||
{% if can_manage_users %}
|
{% if can_manage_users %}
|
||||||
<td class="p-2">
|
<td class="p-2 text-center">
|
||||||
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }})" class="text-blue-500 hover:text-blue-700 mr-4 hover:cursor-pointer" title="Edit User">
|
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }}, '{{ user[5]|replace("'", "\\'") if user[5] else '' }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 mr-2 hover:cursor-pointer" title="Edit User">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to regenerate the API key for this user?');" class="inline mr-2">
|
||||||
|
<input type="hidden" name="action" value="regenerate_api_key">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Regenerate API Key">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
|
||||||
<input type="hidden" name="action" value="delete_user">
|
<input type="hidden" name="action" value="delete_user">
|
||||||
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete User">
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete User">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -120,10 +127,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if can_manage_roles %}
|
{% if can_manage_roles %}
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-blue-500 hover:text-blue-700 hover:cursor-pointer" title="Edit Role">
|
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Role">
|
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +183,11 @@
|
|||||||
<option value="{{ role[0] }}">{{ role[1] }}</option>
|
<option value="{{ role[0] }}">{{ role[1] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<div class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
|
||||||
|
<label class="text-sm font-semibold mb-2 block">API Key</label>
|
||||||
|
<code id="edit-user-api-key" class="text-xs font-mono break-all block bg-gray-200 dark:bg-zinc-800 px-2 py-1 rounded"></code>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Use this API key to authenticate API requests. Keep it secure!</p>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end space-x-2">
|
<div class="flex justify-end space-x-2">
|
||||||
<button type="button" onclick="closeEditUserModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
<button type="button" onclick="closeEditUserModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button>
|
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button>
|
||||||
@@ -347,12 +359,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editUser(userId, name, email, roleId) {
|
function editUser(userId, name, email, roleId, apiKey) {
|
||||||
document.getElementById('edit-user-id').value = userId;
|
document.getElementById('edit-user-id').value = userId;
|
||||||
document.getElementById('edit-user-name').value = name;
|
document.getElementById('edit-user-name').value = name;
|
||||||
document.getElementById('edit-user-email').value = email;
|
document.getElementById('edit-user-email').value = email;
|
||||||
document.getElementById('edit-user-password').value = '';
|
document.getElementById('edit-user-password').value = '';
|
||||||
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
|
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
|
||||||
|
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
|
||||||
document.getElementById('edit-user-modal').classList.remove('hidden');
|
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user