Compare commits
14 Commits
v2.0.0
...
5b606404fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b606404fc | |||
| 8f135c8dd9 | |||
| d476901a53 | |||
| 1346e9e5f5 | |||
| af4f16aa59 | |||
| e6ccba0e0a | |||
| 1a3d47a72e | |||
| 6012566b22 | |||
| fc5699a04c | |||
| 675d477ff9 | |||
| 34856060e8 | |||
| be55503e1c | |||
| b79763be53 | |||
| e961afc36a |
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.env
|
.env
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
static/dist/
|
static/dist/
|
||||||
|
venv/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 JDB-NET
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,28 +1,47 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
|
|
||||||
|
|
||||||
# IP Address Management
|
<h1>JDB-NET IPAM</h1>
|
||||||
|
|
||||||
|
<p>Open source IP address management for homelabs, small businesses, and IT teams.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/jdbnet/ipam/blob/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/jdbnet/ipam" alt="License" />
|
||||||
|
</a>
|
||||||
|
<a href="https://cr.jdbnet.co.uk">
|
||||||
|
<img src="https://img.shields.io/badge/container-cr.jdbnet.co.uk-blue" alt="Container" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://www.jdbnet.co.uk/product/ipam"><strong>☁️ Managed hosting from £8/month →</strong></a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
A Flask-based web application for IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and rack infrastructure through a Vue 3 web interface and a JSON REST API.
|
---
|
||||||
|
|
||||||
## Features
|
Manage subnets, IP assignments, DHCP pools, devices, and rack layout from
|
||||||
|
a single web interface. Built with Flask and Vue 3, deployable with a single
|
||||||
- **Subnet management** - CIDR subnets with automatic IP generation
|
Docker Compose file.
|
||||||
- **IP assignment** - Assign addresses to devices with hostname tracking
|
|
||||||
- **Device management** - Names, descriptions, tags, and custom fields
|
|
||||||
- **DHCP pools** — Configure ranges and excluded IPs per subnet
|
|
||||||
- **Rack management** - U positions with front/back layout
|
|
||||||
- **Site organisation** - Group subnets and devices by location
|
|
||||||
- **Audit logging** - Filterable change history with CSV export
|
|
||||||
- **Role-based access control** - Granular permissions and custom roles
|
|
||||||
- **REST API v2** - Session cookies for the browser, API keys for automation
|
|
||||||
|
|
||||||
## Screenshot
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Docker Compose
|
## Features
|
||||||
|
|
||||||
|
- **Subnet management** - CIDR subnets (/24–/32) with automatic IP generation
|
||||||
|
- **IP assignment** - Assign addresses to devices with hostname tracking and assignment history
|
||||||
|
- **DHCP pools** - Configure ranges and excluded IPs per subnet; pool addresses are kept out of manual assignment
|
||||||
|
- **Device management** - Names, descriptions, tags, custom fields, and bulk creation
|
||||||
|
- **Rack layout** - U positions with front/back face placement and non-networked entries
|
||||||
|
- **Site organisation** - Group subnets and devices by location for multi-site networks
|
||||||
|
- **Global search** - Press `/` to search subnets, IPs, devices, and racks from anywhere
|
||||||
|
- **Audit logging** - Filterable change history with CSV export
|
||||||
|
- **Role-based access control** - Granular permissions, custom roles, and enforced 2FA per role
|
||||||
|
- **REST API v2** - Full JSON API with session cookie and API key authentication
|
||||||
|
- **Custom fields** - Extend devices and subnets with admin-defined fields, no schema changes required
|
||||||
|
- **Organisation branding** - Set your name and logo from Settings or environment variables
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -33,11 +52,33 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_HOST=10.10.2.27
|
- MYSQL_HOST=your_db_host
|
||||||
- MYSQL_USER=ipam
|
- MYSQL_USER=ipam
|
||||||
- MYSQL_PASSWORD=your_password
|
- MYSQL_PASSWORD=your_password
|
||||||
- MYSQL_DATABASE=ipam
|
- MYSQL_DATABASE=ipam
|
||||||
- SECRET_KEY=your_secret_key
|
- SECRET_KEY=your_secret_key # generate with: openssl rand -hex 32
|
||||||
- NAME=Your Organisation
|
|
||||||
- LOGO_PNG=https://example.com/logo.png
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A MySQL or MariaDB database is required. The schema is created automatically
|
||||||
|
on first run. Log in with `admin@example.com` / `password` and change the password immediately.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `MYSQL_HOST` | Yes | Database host |
|
||||||
|
| `MYSQL_USER` | Yes | Database user |
|
||||||
|
| `MYSQL_PASSWORD` | Yes | Database password |
|
||||||
|
| `MYSQL_DATABASE` | Yes | Database name |
|
||||||
|
| `SECRET_KEY` | Yes | Flask secret key - use a long random string |
|
||||||
|
|
||||||
|
## Managed hosting
|
||||||
|
|
||||||
|
Don't want to run it yourself? JDB-NET offers fully managed hosting from
|
||||||
|
**£8/month** - provisioned in under 10 minutes, no maintenance required.
|
||||||
|
|
||||||
|
[→ jdbnet.co.uk/products/ipam](https://www.jdbnet.co.uk/product/ipam)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
@@ -17,6 +17,11 @@ from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
|
|||||||
import pyotp
|
import pyotp
|
||||||
import qrcode
|
import qrcode
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
|
import requests
|
||||||
|
import warnings
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
from authlib.jose import jwt, JsonWebKey
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, session, request, abort, jsonify, redirect,
|
Flask, session, request, abort, jsonify, redirect,
|
||||||
@@ -37,11 +42,14 @@ app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
|
|||||||
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
|
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
|
||||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
|
app.config['NAME'] = ''
|
||||||
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
|
app.config['LOGO_PNG'] = ''
|
||||||
app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
|
app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
|
||||||
|
|
||||||
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
|
from db import (
|
||||||
|
init_db, hash_password, get_db_connection, verify_password, generate_api_key,
|
||||||
|
load_org_settings, save_org_settings, org_branding,
|
||||||
|
)
|
||||||
|
|
||||||
# ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
|
# ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
|
||||||
def generate_totp_secret():
|
def generate_totp_secret():
|
||||||
@@ -1216,6 +1224,173 @@ def group_devices_by_site(devices):
|
|||||||
|
|
||||||
# ── Auth & account (v2) ───────────────────────────────────────────────────────
|
# ── Auth & account (v2) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_sso_settings():
|
||||||
|
import os
|
||||||
|
return {
|
||||||
|
"enabled": os.environ.get("SSO_ENABLED", "false").lower() == "true",
|
||||||
|
"issuer": os.environ.get("SSO_ISSUER_URL", ""),
|
||||||
|
"client_id": os.environ.get("SSO_CLIENT_ID", ""),
|
||||||
|
"client_secret": os.environ.get("SSO_CLIENT_SECRET", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_public_base_url():
|
||||||
|
from flask import request
|
||||||
|
return request.host_url.rstrip("/")
|
||||||
|
|
||||||
|
@app.route("/api/v2/auth/capabilities", methods=["GET"])
|
||||||
|
def api_auth_capabilities():
|
||||||
|
sso = get_sso_settings()
|
||||||
|
return jsonify({
|
||||||
|
"sso_enabled": sso["enabled"],
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route("/api/v2/auth/sso/login", methods=["GET"])
|
||||||
|
def api_sso_login():
|
||||||
|
import secrets, base64, hashlib, requests, logging
|
||||||
|
sso = get_sso_settings()
|
||||||
|
if not sso["enabled"] or not sso["issuer"] or not sso["client_id"]:
|
||||||
|
return jsonify({"error": "SSO is not configured"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{sso['issuer'].rstrip('/')}/.well-known/openid-configuration", timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
oidc_config = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to fetch OIDC configuration: {e}")
|
||||||
|
return jsonify({"error": "Failed to fetch OIDC configuration"}), 500
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
code_verifier = secrets.token_urlsafe(64)
|
||||||
|
|
||||||
|
code_challenge = base64.urlsafe_b64encode(
|
||||||
|
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||||
|
).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
session["sso_state"] = state
|
||||||
|
session["sso_code_verifier"] = code_verifier
|
||||||
|
session.modified = True
|
||||||
|
|
||||||
|
redirect_uri = get_public_base_url() + "/sso/callback"
|
||||||
|
auth_endpoint = oidc_config["authorization_endpoint"]
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{auth_endpoint}?"
|
||||||
|
f"response_type=code&"
|
||||||
|
f"client_id={sso['client_id']}&"
|
||||||
|
f"redirect_uri={redirect_uri}&"
|
||||||
|
f"scope=openid email profile&"
|
||||||
|
f"state={state}&"
|
||||||
|
f"code_challenge={code_challenge}&"
|
||||||
|
f"code_challenge_method=S256"
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"url": url})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v2/auth/sso/callback", methods=["POST"])
|
||||||
|
def api_sso_callback():
|
||||||
|
import requests, logging
|
||||||
|
from flask import current_app
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
code = data.get("code")
|
||||||
|
state = data.get("state")
|
||||||
|
|
||||||
|
if not code or not state:
|
||||||
|
return jsonify({"error": "Missing code or state"}), 400
|
||||||
|
|
||||||
|
if state != session.get("sso_state"):
|
||||||
|
return jsonify({"error": "Invalid state"}), 400
|
||||||
|
|
||||||
|
code_verifier = session.get("sso_code_verifier")
|
||||||
|
if not code_verifier:
|
||||||
|
return jsonify({"error": "Missing code verifier in session"}), 400
|
||||||
|
|
||||||
|
sso = get_sso_settings()
|
||||||
|
if not sso["enabled"]:
|
||||||
|
return jsonify({"error": "SSO is not enabled"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{sso['issuer'].rstrip('/')}/.well-known/openid-configuration", timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
oidc_config = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to fetch OIDC configuration: {e}")
|
||||||
|
return jsonify({"error": "Failed to fetch OIDC configuration"}), 500
|
||||||
|
|
||||||
|
token_endpoint = oidc_config["token_endpoint"]
|
||||||
|
redirect_uri = get_public_base_url() + "/sso/callback"
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_resp = requests.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": sso["client_id"],
|
||||||
|
"client_secret": sso["client_secret"],
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
token_resp.raise_for_status()
|
||||||
|
token_data = token_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"SSO token exchange failed: {e}")
|
||||||
|
return jsonify({"error": "SSO token exchange failed"}), 401
|
||||||
|
|
||||||
|
id_token = token_data.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
return jsonify({"error": "No ID token returned"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
jwks_uri = oidc_config.get("jwks_uri")
|
||||||
|
if not jwks_uri:
|
||||||
|
raise ValueError("OIDC configuration is missing jwks_uri")
|
||||||
|
|
||||||
|
jwks_resp = requests.get(jwks_uri, timeout=10)
|
||||||
|
jwks_resp.raise_for_status()
|
||||||
|
jwks_data = jwks_resp.json()
|
||||||
|
keys = JsonWebKey.import_key_set(jwks_data)
|
||||||
|
|
||||||
|
claims_options = {
|
||||||
|
"iss": {"essential": True, "value": sso["issuer"]},
|
||||||
|
"aud": {"essential": True, "value": sso["client_id"]},
|
||||||
|
"exp": {"essential": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
claims = jwt.decode(id_token, keys, claims_options=claims_options)
|
||||||
|
claims.validate()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to decode or validate ID token: {e}")
|
||||||
|
return jsonify({"error": "Invalid ID token"}), 400
|
||||||
|
|
||||||
|
email = claims.get("email")
|
||||||
|
if not email:
|
||||||
|
return jsonify({"error": "No email provided in ID token"}), 400
|
||||||
|
|
||||||
|
email = email.strip().lower()
|
||||||
|
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, name, email, password as password_hash, role_id, totp_secret, totp_enabled, two_fa_setup_complete "
|
||||||
|
"FROM User WHERE email = %s",
|
||||||
|
(email,),
|
||||||
|
)
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
add_audit_log(None, "sso_failed", f"SSO login failed: User not found ({email})", conn=conn)
|
||||||
|
return jsonify({"error": "Account not found."}), 403
|
||||||
|
|
||||||
|
establish_user_session(user['id'], conn)
|
||||||
|
add_audit_log(user['id'], "login", "Successful SSO login", conn=conn)
|
||||||
|
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/v2/auth/login', methods=['POST'])
|
@app.route('/api/v2/auth/login', methods=['POST'])
|
||||||
def api_auth_login():
|
def api_auth_login():
|
||||||
data = json_body()
|
data = json_body()
|
||||||
@@ -1260,17 +1435,18 @@ def api_auth_logout():
|
|||||||
|
|
||||||
@app.route('/api/v2/auth/me', methods=['GET'])
|
@app.route('/api/v2/auth/me', methods=['GET'])
|
||||||
def api_auth_me():
|
def api_auth_me():
|
||||||
|
branding = org_branding()
|
||||||
user = resolve_auth()
|
user = resolve_auth()
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'logged_in': False,
|
'logged_in': False,
|
||||||
'app_version': app.config['VERSION'],
|
'app_version': app.config['VERSION'],
|
||||||
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
|
'org': branding,
|
||||||
})
|
})
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'logged_in': True,
|
'logged_in': True,
|
||||||
'app_version': app.config['VERSION'],
|
'app_version': app.config['VERSION'],
|
||||||
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
|
'org': branding,
|
||||||
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
|
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
|
||||||
'permissions': sorted(user.get('permissions') or []),
|
'permissions': sorted(user.get('permissions') or []),
|
||||||
})
|
})
|
||||||
@@ -1454,7 +1630,9 @@ def api_info():
|
|||||||
'id': get_current_user_id(),
|
'id': get_current_user_id(),
|
||||||
'name': current_user()['name'],
|
'name': current_user()['name'],
|
||||||
'email': current_user()['email']
|
'email': current_user()['email']
|
||||||
}
|
} if current_user() else None,
|
||||||
|
'permissions': list(current_user()['permissions']) if current_user() else [],
|
||||||
|
'org': org_branding(current_app),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Devices API
|
# Devices API
|
||||||
@@ -3048,6 +3226,39 @@ def api_delete_role(role_id):
|
|||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/v2/settings', methods=['GET'])
|
||||||
|
@require_permission('view_settings')
|
||||||
|
def api_get_settings():
|
||||||
|
return jsonify({
|
||||||
|
'org_name': app.config['NAME'],
|
||||||
|
'org_logo': app.config['LOGO_PNG'],
|
||||||
|
'accent_color': app.config.get('ACCENT_COLOR') or '#1ebe8a',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/v2/settings', methods=['PUT'])
|
||||||
|
@require_permission('manage_settings')
|
||||||
|
def api_update_settings():
|
||||||
|
data = json_body()
|
||||||
|
name = (data.get('org_name') or '').strip()
|
||||||
|
logo = (data.get('org_logo') or '').strip()
|
||||||
|
accent_color = (data.get('accent_color') or '').strip()
|
||||||
|
save_org_settings(current_app, name, logo, accent_color)
|
||||||
|
with get_db_connection(current_app) as conn:
|
||||||
|
add_audit_log(
|
||||||
|
get_current_user_id(),
|
||||||
|
'update_settings',
|
||||||
|
f"Updated organisation settings (name: {name or '(default)'})",
|
||||||
|
conn=conn,
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'org_name': name,
|
||||||
|
'org_logo': logo,
|
||||||
|
'accent_color': accent_color or '#1ebe8a',
|
||||||
|
'org': org_branding(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/v2/permissions', methods=['GET'])
|
@app.route('/api/v2/permissions', methods=['GET'])
|
||||||
@require_permission('manage_roles')
|
@require_permission('manage_roles')
|
||||||
def api_permissions():
|
def api_permissions():
|
||||||
@@ -3166,7 +3377,7 @@ DIST = os.path.join(STATIC_ROOT, 'dist')
|
|||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
logo = app.config['LOGO_PNG']
|
logo = org_branding()['logo']
|
||||||
if logo.startswith(('http://', 'https://')):
|
if logo.startswith(('http://', 'https://')):
|
||||||
return redirect(logo)
|
return redirect(logo)
|
||||||
path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo)
|
path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo)
|
||||||
@@ -3200,6 +3411,7 @@ def spa(path):
|
|||||||
|
|
||||||
# ── App startup ───────────────────────────────────────────────────────────────
|
# ── App startup ───────────────────────────────────────────────────────────────
|
||||||
init_db(app)
|
init_db(app)
|
||||||
|
load_org_settings(app)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ def init_db(app=None):
|
|||||||
FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE
|
FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Setting (
|
||||||
|
setting_key VARCHAR(255) PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
# Add role_id column to User table if it doesn't exist
|
# Add role_id column to User table if it doesn't exist
|
||||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
|
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
|
||||||
@@ -363,6 +370,8 @@ def init_db(app=None):
|
|||||||
# Admin permissions
|
# Admin permissions
|
||||||
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
||||||
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
||||||
|
('view_settings', 'View Settings page', 'Admin'),
|
||||||
|
('manage_settings', 'Manage organisation settings', 'Admin'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Insert permissions
|
# Insert permissions
|
||||||
@@ -596,3 +605,90 @@ def run_v2_migrations(cursor, conn):
|
|||||||
logging.info("Removed orphaned permission: %s", perm_name)
|
logging.info("Removed orphaned permission: %s", perm_name)
|
||||||
|
|
||||||
logging.info("v2 database migrations complete")
|
logging.info("v2 database migrations complete")
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ORG_NAME = 'JDB-NET'
|
||||||
|
DEFAULT_ORG_LOGO = 'https://assets.jdbnet.co.uk/projects/ipam.png'
|
||||||
|
DEFAULT_ACCENT_COLOR = '#1ebe8a'
|
||||||
|
ORG_NAME_KEY = 'org_name'
|
||||||
|
ORG_LOGO_KEY = 'org_logo'
|
||||||
|
ACCENT_COLOR_KEY = 'accent_color'
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(cursor, key):
|
||||||
|
cursor.execute('SELECT value FROM Setting WHERE setting_key = %s', (key,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row[0] is None:
|
||||||
|
return ''
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(cursor, key, value):
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO Setting (setting_key, value) VALUES (%s, %s) '
|
||||||
|
'ON DUPLICATE KEY UPDATE value = %s',
|
||||||
|
(key, value, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_org_settings(app):
|
||||||
|
"""Load org name/logo from DB; migrate from env vars when DB values are blank."""
|
||||||
|
env_name = (os.environ.get('NAME') or '').strip()
|
||||||
|
env_logo = (os.environ.get('LOGO_PNG') or '').strip()
|
||||||
|
|
||||||
|
conn = get_db_connection(app)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
name = get_setting(cursor, ORG_NAME_KEY).strip()
|
||||||
|
logo = get_setting(cursor, ORG_LOGO_KEY).strip()
|
||||||
|
accent = get_setting(cursor, ACCENT_COLOR_KEY).strip()
|
||||||
|
|
||||||
|
if not name and env_name:
|
||||||
|
name = env_name
|
||||||
|
set_setting(cursor, ORG_NAME_KEY, name)
|
||||||
|
logging.info("Migrated organisation name from NAME env var to database")
|
||||||
|
|
||||||
|
if not logo and env_logo:
|
||||||
|
logo = env_logo
|
||||||
|
set_setting(cursor, ORG_LOGO_KEY, logo)
|
||||||
|
logging.info("Migrated organisation logo from LOGO_PNG env var to database")
|
||||||
|
|
||||||
|
app.config['NAME'] = name
|
||||||
|
app.config['LOGO_PNG'] = logo
|
||||||
|
app.config['ACCENT_COLOR'] = accent
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_org_settings(app, name, logo, accent_color=None):
|
||||||
|
conn = get_db_connection(app)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
set_setting(cursor, ORG_NAME_KEY, name)
|
||||||
|
set_setting(cursor, ORG_LOGO_KEY, logo)
|
||||||
|
if accent_color is not None:
|
||||||
|
set_setting(cursor, ACCENT_COLOR_KEY, accent_color)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
app.config['NAME'] = name
|
||||||
|
app.config['LOGO_PNG'] = logo
|
||||||
|
if accent_color is not None:
|
||||||
|
app.config['ACCENT_COLOR'] = accent_color
|
||||||
|
|
||||||
|
|
||||||
|
def org_branding(app=None):
|
||||||
|
"""Return stored org branding with defaults applied for display."""
|
||||||
|
if app is None:
|
||||||
|
app = current_app
|
||||||
|
name = (app.config.get('NAME') or '').strip()
|
||||||
|
logo = (app.config.get('LOGO_PNG') or '').strip()
|
||||||
|
accent = (app.config.get('ACCENT_COLOR') or '').strip()
|
||||||
|
return {
|
||||||
|
'name': name or DEFAULT_ORG_NAME,
|
||||||
|
'logo': logo or DEFAULT_ORG_LOGO,
|
||||||
|
'accent_color': accent or DEFAULT_ACCENT_COLOR,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch, onMounted } from "vue";
|
||||||
import { RouterView } from "vue-router";
|
import { RouterView } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
function hexToRgb(hex: string) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim());
|
||||||
|
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccentColor() {
|
||||||
|
const color = auth.org?.accent_color;
|
||||||
|
if (color) {
|
||||||
|
const rgb = color.startsWith('#') ? hexToRgb(color) : color;
|
||||||
|
if (rgb) {
|
||||||
|
document.documentElement.style.setProperty("--accent", rgb);
|
||||||
|
document.documentElement.style.setProperty("--accent-muted", rgb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyAccentColor();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => auth.org?.accent_color, applyAccentColor);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
+24
-1
@@ -23,7 +23,7 @@ function fetchApi(path: string, init?: RequestInit) {
|
|||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
logged_in: boolean;
|
logged_in: boolean;
|
||||||
app_version?: string;
|
app_version?: string;
|
||||||
org?: { name: string; logo: string };
|
org?: { name: string; logo: string; accent_color?: string };
|
||||||
user?: { id: number; name: string; email: string };
|
user?: { id: number; name: string; email: string };
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
}
|
}
|
||||||
@@ -146,6 +146,21 @@ export interface AuditParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
async capabilities() {
|
||||||
|
return handle<{ sso_enabled: boolean }>(await fetchApi("/api/v2/auth/capabilities"));
|
||||||
|
},
|
||||||
|
async startSsoLogin() {
|
||||||
|
return handle<{ url: string }>(await fetchApi("/api/v2/auth/sso/login"));
|
||||||
|
},
|
||||||
|
async ssoCallback(code: string, state: string) {
|
||||||
|
return handle<{ ok?: boolean; requires_2fa?: boolean; requires_setup?: boolean }>(
|
||||||
|
await fetchApi("/api/v2/auth/sso/callback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ code, state }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
async me(): Promise<MeResponse> {
|
async me(): Promise<MeResponse> {
|
||||||
return handle(await fetchApi("/api/v2/auth/me"));
|
return handle(await fetchApi("/api/v2/auth/me"));
|
||||||
},
|
},
|
||||||
@@ -399,6 +414,14 @@ export const api = {
|
|||||||
);
|
);
|
||||||
return d.items;
|
return d.items;
|
||||||
},
|
},
|
||||||
|
async settings() {
|
||||||
|
return handle<{ org_name: string; org_logo: string; accent_color?: string }>(await fetchApi("/api/v2/settings"));
|
||||||
|
},
|
||||||
|
async updateSettings(body: { org_name: string; org_logo: string; accent_color?: string }) {
|
||||||
|
return handle<{ org_name: string; org_logo: string; accent_color?: string; org?: { name: string; logo: string; accent_color?: string } }>(
|
||||||
|
await fetchApi("/api/v2/settings", { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }),
|
||||||
|
);
|
||||||
|
},
|
||||||
async customFields(entityType: string) {
|
async customFields(entityType: string) {
|
||||||
const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
|
const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
|
||||||
return d.items;
|
return d.items;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||||
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||||
import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User, Network } from "lucide-vue-next";
|
import { Menu, Search, X, Home, Server, Grid3x3, Settings, SlidersHorizontal, Users, Tag, Layers, FileText, User, Network } from "lucide-vue-next";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { api } from "@/api";
|
import { api } from "@/api";
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ const nav = computed(() =>
|
|||||||
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
|
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
|
||||||
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
|
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
|
||||||
{ to: "/users", label: "Users", icon: Users, perm: "view_users" },
|
{ to: "/users", label: "Users", icon: Users, perm: "view_users" },
|
||||||
|
{ to: "/settings", label: "Settings", icon: SlidersHorizontal, perm: "view_settings" },
|
||||||
{ to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" },
|
{ to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" },
|
||||||
{ to: "/account", label: "Account", icon: User, perm: null },
|
{ to: "/account", label: "Account", icon: User, perm: null },
|
||||||
].filter((n) => !n.perm || auth.can(n.perm)),
|
].filter((n) => !n.perm || auth.can(n.perm)),
|
||||||
@@ -90,24 +91,24 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-screen bg-surface font-sans">
|
<div class="flex h-screen overflow-hidden bg-surface font-sans">
|
||||||
<!-- Mobile overlay -->
|
<!-- Mobile overlay -->
|
||||||
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
|
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<aside
|
||||||
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0"
|
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:h-screen lg:translate-x-0"
|
||||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800">
|
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-slate-200 px-4 dark:border-slate-800">
|
||||||
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" />
|
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-7 shrink-0 rounded" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1 leading-tight">
|
||||||
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
|
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
|
||||||
<div class="text-xs text-slate-500">{{ auth.version }}</div>
|
<div class="text-xs text-slate-500">{{ auth.version }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
|
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 overflow-y-auto p-2">
|
<nav class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in nav"
|
v-for="item in nav"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
@@ -122,15 +123,15 @@ onUnmounted(() => {
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="border-t border-slate-200 p-3 dark:border-slate-800">
|
<div class="shrink-0 border-t border-slate-200 p-3 dark:border-slate-800">
|
||||||
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
|
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
|
||||||
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
|
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800">
|
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 dark:border-slate-800">
|
||||||
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
|
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
|
||||||
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
|
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
|
||||||
<button
|
<button
|
||||||
@@ -141,7 +142,7 @@ onUnmounted(() => {
|
|||||||
<Search class="h-5 w-5" />
|
<Search class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 overflow-auto p-4 md:p-6">
|
<main class="min-h-0 flex-1 overflow-auto p-4 md:p-6">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const router = createRouter({
|
|||||||
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
|
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
|
||||||
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
|
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
|
||||||
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
|
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
|
||||||
|
{ path: "/sso/callback", name: "sso-callback", component: () => import("@/views/SSOCallbackView.vue"), meta: { public: true } },
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
component: () => import("@/components/AppLayout.vue"),
|
component: () => import("@/components/AppLayout.vue"),
|
||||||
@@ -28,6 +29,7 @@ const router = createRouter({
|
|||||||
{ path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") },
|
{ path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") },
|
||||||
{ path: "admin", redirect: "/subnets/manage" },
|
{ path: "admin", redirect: "/subnets/manage" },
|
||||||
{ path: "users", name: "users", component: () => import("@/views/UsersView.vue") },
|
{ path: "users", name: "users", component: () => import("@/views/UsersView.vue") },
|
||||||
|
{ path: "settings", name: "settings", component: () => import("@/views/SettingsView.vue") },
|
||||||
{ path: "account", name: "account", component: () => import("@/views/AccountView.vue") },
|
{ path: "account", name: "account", component: () => import("@/views/AccountView.vue") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
user: null as MeResponse["user"] | null,
|
user: null as MeResponse["user"] | null,
|
||||||
permissions: [] as string[],
|
permissions: [] as string[],
|
||||||
org: { name: "IPAM", logo: "" },
|
org: { name: "IPAM", logo: "", accent_color: "#1ebe8a" },
|
||||||
version: "unknown",
|
version: "unknown",
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
|
|||||||
@@ -4,19 +4,19 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--surface: 248 250 252;
|
--surface: 240 246 252;
|
||||||
--surface-raised: 255 255 255;
|
--surface-raised: 255 255 255;
|
||||||
--surface-overlay: 241 245 249;
|
--surface-overlay: 230 237 243;
|
||||||
--accent: 6 182 212;
|
--accent: 30 190 138;
|
||||||
--accent-muted: 8 145 178;
|
--accent-muted: 24 152 110;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--surface: 15 20 25;
|
--surface: 13 17 23;
|
||||||
--surface-raised: 21 28 36;
|
--surface-raised: 22 27 34;
|
||||||
--surface-overlay: 26 35 46;
|
--surface-overlay: 33 38 45;
|
||||||
--accent: 34 211 238;
|
--accent: 30 190 138;
|
||||||
--accent-muted: 6 182 212;
|
--accent-muted: 24 152 110;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async function regenCodes() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="newBackupCodes.length">
|
<div v-if="newBackupCodes.length">
|
||||||
<p class="text-sm font-medium text-accent">New backup codes — save these now:</p>
|
<p class="text-sm font-medium text-accent">New backup codes - save these now:</p>
|
||||||
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
|
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
|
||||||
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
|
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -87,7 +87,7 @@ async function regenCodes() {
|
|||||||
class="text-sm text-red-500 hover:underline"
|
class="text-sm text-red-500 hover:underline"
|
||||||
@click="disable2fa"
|
@click="disable2fa"
|
||||||
>Disable 2FA</button>
|
>Disable 2FA</button>
|
||||||
<p v-else class="text-sm text-slate-500">Your role requires 2FA — it cannot be disabled.</p>
|
<p v-else class="text-sm text-slate-500">Your role requires 2FA - it cannot be disabled.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface DashboardStats {
|
|||||||
available_ips: number;
|
available_ips: number;
|
||||||
utilization_percent: number;
|
utilization_percent: number;
|
||||||
subnet_count: number;
|
subnet_count: number;
|
||||||
alerting_subnets: number;
|
|
||||||
device_count: number;
|
device_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ interface SubnetOverviewRow {
|
|||||||
vlan_id?: number;
|
vlan_id?: number;
|
||||||
utilization: number;
|
utilization: number;
|
||||||
available: number;
|
available: number;
|
||||||
status: "active" | "alerting";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityPoint {
|
interface ActivityPoint {
|
||||||
@@ -38,7 +36,7 @@ const activity = ref<ActivityPoint[]>([]);
|
|||||||
|
|
||||||
const donutStyle = computed(() => {
|
const donutStyle = computed(() => {
|
||||||
const pct = stats.value?.utilization_percent ?? 0;
|
const pct = stats.value?.utilization_percent ?? 0;
|
||||||
return { background: `conic-gradient(rgb(6 182 212) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
|
return { background: `conic-gradient(rgb(var(--accent)) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
|
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
|
||||||
@@ -93,10 +91,7 @@ function formatHour(h: number) {
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
|
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
|
||||||
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
|
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
|
||||||
<div class="text-sm text-slate-500">
|
<div class="text-sm text-slate-500">Total</div>
|
||||||
Total
|
|
||||||
<span v-if="stats.alerting_subnets" class="text-red-500">/ {{ stats.alerting_subnets }} alerting</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card flex items-start gap-4">
|
<div class="card flex items-start gap-4">
|
||||||
@@ -133,7 +128,7 @@ function formatHour(h: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="font-semibold">Activity — last 24 hours</h2>
|
<h2 class="font-semibold">Activity - last 24 hours</h2>
|
||||||
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
|
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
|
||||||
<div class="mt-4 flex h-40 items-end gap-0.5">
|
<div class="mt-4 flex h-40 items-end gap-0.5">
|
||||||
<div
|
<div
|
||||||
@@ -166,7 +161,6 @@ function formatHour(h: number) {
|
|||||||
<th class="p-2">Utilised</th>
|
<th class="p-2">Utilised</th>
|
||||||
<th class="p-2">Available</th>
|
<th class="p-2">Available</th>
|
||||||
<th class="p-2">Site</th>
|
<th class="p-2">Site</th>
|
||||||
<th class="p-2">Status</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -174,7 +168,6 @@ function formatHour(h: number) {
|
|||||||
v-for="s in subnetOverview"
|
v-for="s in subnetOverview"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
class="border-b border-slate-100 dark:border-slate-800"
|
class="border-b border-slate-100 dark:border-slate-800"
|
||||||
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
|
|
||||||
>
|
>
|
||||||
<td class="p-2">
|
<td class="p-2">
|
||||||
<RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
|
<RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
|
||||||
@@ -184,8 +177,7 @@ function formatHour(h: number) {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
|
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full"
|
class="h-full rounded-full bg-accent"
|
||||||
:class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'"
|
|
||||||
:style="{ width: `${s.utilization}%` }"
|
:style="{ width: `${s.utilization}%` }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,18 +186,9 @@ function formatHour(h: number) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="p-2">{{ s.available }}</td>
|
<td class="p-2">{{ s.available }}</td>
|
||||||
<td class="p-2">{{ s.site }}</td>
|
<td class="p-2">{{ s.site }}</td>
|
||||||
<td class="p-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
|
||||||
:class="s.status === 'alerting' ? 'bg-red-500/15 text-red-500' : 'bg-accent/15 text-accent'"
|
|
||||||
>
|
|
||||||
<span class="h-1.5 w-1.5 rounded-full" :class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'" />
|
|
||||||
{{ s.status === 'alerting' ? 'Alerting' : 'Active' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!subnetOverview.length">
|
<tr v-if="!subnetOverview.length">
|
||||||
<td colspan="6" class="p-4 text-center text-slate-500">No subnets configured.</td>
|
<td colspan="5" class="p-4 text-center text-slate-500">No subnets configured.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { useRoute, useRouter, RouterLink } from "vue-router";
|
import { useRoute, useRouter, RouterLink } from "vue-router";
|
||||||
import { api, type Device, type Tag, type Subnet } from "@/api";
|
import { api, type Device, type Tag, type Subnet } from "@/api";
|
||||||
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
||||||
@@ -70,7 +70,15 @@ async function loadDevice() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadDevice);
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
showAssignIp.value = false;
|
||||||
|
err.value = "";
|
||||||
|
loadDevice();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
async function loadAvailableIps(subnetId: number) {
|
async function loadAvailableIps(subnetId: number) {
|
||||||
if (!subnetId) {
|
if (!subnetId) {
|
||||||
|
|||||||
@@ -1,16 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const err = ref("");
|
const err = ref("");
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
|
const ssoEnabled = ref(false);
|
||||||
|
const ssoLoading = ref(false);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const caps = await api.capabilities();
|
||||||
|
ssoEnabled.value = caps.sso_enabled;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startSsoLogin() {
|
||||||
|
err.value = "";
|
||||||
|
ssoLoading.value = true;
|
||||||
|
try {
|
||||||
|
const { url } = await api.startSsoLogin();
|
||||||
|
window.location.href = url;
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "SSO initiation failed";
|
||||||
|
ssoLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
err.value = "";
|
err.value = "";
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
@@ -48,7 +72,12 @@ async function submit() {
|
|||||||
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
|
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
|
<button type="submit" class="btn-primary w-full" :disabled="busy || ssoLoading">{{ busy ? "Signing in…" : "Sign in" }}</button>
|
||||||
|
<div v-if="ssoEnabled" class="pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||||
|
<button type="button" class="btn-secondary w-full" :disabled="ssoLoading || busy" @click="startSsoLogin">
|
||||||
|
{{ ssoLoading ? 'Redirecting…' : 'Sign in with Single Sign-On' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = (route.query.code as string) || urlParams.get("code");
|
||||||
|
const state = (route.query.state as string) || urlParams.get("state");
|
||||||
|
|
||||||
|
const ssoError = (route.query.error as string) || urlParams.get("error");
|
||||||
|
const ssoErrorDesc = (route.query.error_description as string) || urlParams.get("error_description");
|
||||||
|
|
||||||
|
if (ssoError) {
|
||||||
|
err.value = `SSO Error: ${ssoError} - ${ssoErrorDesc || "No description provided"}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
err.value = `Missing callback parameters. URL: ${window.location.search}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.ssoCallback(code, state);
|
||||||
|
if (res.requires_setup) {
|
||||||
|
router.push("/setup-2fa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.requires_2fa) {
|
||||||
|
router.push("/verify-2fa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await auth.fetchMe();
|
||||||
|
router.push("/");
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "SSO Login failed";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||||
|
<div class="card w-full max-w-md p-8 text-center">
|
||||||
|
<h1 class="text-2xl font-semibold">Single Sign-On</h1>
|
||||||
|
<p v-if="err" class="mt-4 text-red-500">{{ err }}</p>
|
||||||
|
<p v-else class="mt-4 text-slate-500">Completing sign-in…</p>
|
||||||
|
<div v-if="err" class="mt-6">
|
||||||
|
<RouterLink to="/login" class="btn-primary inline-block w-full text-center">Back to login</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const form = ref({ org_name: "", org_logo: "", accent_color: "" });
|
||||||
|
const msg = ref("");
|
||||||
|
const err = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await api.settings();
|
||||||
|
form.value = { org_name: data.org_name, org_logo: data.org_logo, accent_color: data.accent_color || "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
err.value = "";
|
||||||
|
msg.value = "";
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const data = await api.updateSettings(form.value);
|
||||||
|
form.value = { org_name: data.org_name, org_logo: data.org_logo, accent_color: data.accent_color || "" };
|
||||||
|
if (data.org) auth.org = data.org;
|
||||||
|
else await auth.fetchMe();
|
||||||
|
msg.value = "Settings saved";
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Settings</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Configure organisation branding shown in the header and browser tab.</p>
|
||||||
|
|
||||||
|
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Organisation name</label>
|
||||||
|
<input v-model="form.org_name" class="input-field" placeholder="Your Organisation" />
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Shown as “{{ form.org_name || "Organisation" }} IPAM” in the sidebar.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Logo URL</label>
|
||||||
|
<input v-model="form.org_logo" class="input-field font-mono text-sm" placeholder="https://example.com/logo.png" />
|
||||||
|
<p class="mt-1 text-xs text-slate-500">URL or path to a PNG logo. Also used as the favicon.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.org_logo" class="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500">Preview</p>
|
||||||
|
<img :src="form.org_logo" alt="" class="h-10 rounded" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Accent Color</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input type="color" v-model="form.accent_color" class="h-10 w-16 cursor-pointer rounded border border-slate-200 bg-transparent p-0.5 dark:border-slate-700" />
|
||||||
|
<input v-model="form.accent_color" class="input-field font-mono text-sm w-32" placeholder="#1ebe8a" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Hex format e.g. "#1ebe8a". Leave blank to use default.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="auth.can('manage_settings')" class="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="busy">{{ busy ? "Saving…" : "Save" }}</button>
|
||||||
|
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-slate-500">You can view settings but do not have permission to change them.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -140,10 +140,21 @@ async function delRole(id: number) {
|
|||||||
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
|
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2">
|
<li
|
||||||
<span>{{ u.name }} <span class="text-slate-500"><{{ u.email }}></span></span>
|
class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
|
||||||
|
>
|
||||||
|
<span>User</span>
|
||||||
|
<span>Role</span>
|
||||||
|
<span v-if="auth.can('manage_users')" class="text-right">Actions</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="u in users"
|
||||||
|
:key="u.id"
|
||||||
|
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
|
||||||
|
>
|
||||||
|
<span class="min-w-0">{{ u.name }} <span class="text-slate-500"><{{ u.email }}></span></span>
|
||||||
<span class="text-sm text-slate-500">{{ u.role_name }}</span>
|
<span class="text-sm text-slate-500">{{ u.role_name }}</span>
|
||||||
<div v-if="auth.can('manage_users')" class="flex gap-2">
|
<div v-if="auth.can('manage_users')" class="flex gap-2 sm:justify-end">
|
||||||
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
|
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
|
||||||
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
|
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
|
||||||
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
|
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
|
||||||
@@ -219,7 +230,7 @@ async function delRole(id: number) {
|
|||||||
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
|
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
|
||||||
<div class="card w-full max-w-md space-y-3">
|
<div class="card w-full max-w-md space-y-3">
|
||||||
<h2 class="text-lg font-semibold">New API key</h2>
|
<h2 class="text-lg font-semibold">New API key</h2>
|
||||||
<p class="text-sm text-slate-500">Copy this key now — it won't be shown again.</p>
|
<p class="text-sm text-slate-500">Copy this key now - it won't be shown again.</p>
|
||||||
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
|
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
|
||||||
<button class="btn-primary" @click="showApiKey = ''">Done</button>
|
<button class="btn-primary" @click="showApiKey = ''">Done</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -3,4 +3,6 @@ mysql-connector-python
|
|||||||
dotenv
|
dotenv
|
||||||
gunicorn
|
gunicorn
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
|
Authlib
|
||||||
|
requests
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reset a user's password (admin CLI). Uses MYSQL_* env vars from .env."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['MYSQL_HOST'] = os.environ.get('MYSQL_HOST', 'localhost')
|
||||||
|
app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user')
|
||||||
|
app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
|
||||||
|
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
|
||||||
|
|
||||||
|
from db import get_db_connection, hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(email, password):
|
||||||
|
email = email.strip()
|
||||||
|
if not email:
|
||||||
|
raise SystemExit('Email is required.')
|
||||||
|
|
||||||
|
conn = get_db_connection(app)
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT id, name FROM User WHERE email = %s', (email,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise SystemExit(f'No user found with email: {email}')
|
||||||
|
|
||||||
|
user_id, name = row
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE User SET password = %s WHERE id = %s',
|
||||||
|
(hash_password(password), user_id),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Reset an IPAM user password.',
|
||||||
|
)
|
||||||
|
parser.add_argument('email', help='User email address')
|
||||||
|
parser.add_argument(
|
||||||
|
'--password', '-p',
|
||||||
|
help='New password (prompted securely if omitted)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--generate', '-g',
|
||||||
|
action='store_true',
|
||||||
|
help='Generate a random password and print it',
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.generate and args.password:
|
||||||
|
raise SystemExit('Use either --password or --generate, not both.')
|
||||||
|
|
||||||
|
if args.generate:
|
||||||
|
password = secrets.token_urlsafe(16)
|
||||||
|
elif args.password:
|
||||||
|
password = args.password
|
||||||
|
else:
|
||||||
|
password = getpass.getpass('New password: ')
|
||||||
|
confirm = getpass.getpass('Confirm password: ')
|
||||||
|
if password != confirm:
|
||||||
|
raise SystemExit('Passwords do not match.')
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
raise SystemExit('Password cannot be empty.')
|
||||||
|
|
||||||
|
name = reset_password(args.email, password)
|
||||||
|
print(f'Password reset for {name} ({args.email}).')
|
||||||
|
if args.generate:
|
||||||
|
print(f'Generated password: {password}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
if [ ! -f static/dist/index.html ]; then
|
|
||||||
echo "Building frontend..."
|
echo "Building frontend UI..."
|
||||||
(cd frontend && npm ci && npm run build)
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "Setting up Python virtual environment..."
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
python3 -m venv venv
|
||||||
fi
|
fi
|
||||||
echo "Starting app..."
|
|
||||||
python app.py
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
echo "Starting server..."
|
||||||
|
python app.py serve
|
||||||
|
|||||||
Reference in New Issue
Block a user