8 Commits

Author SHA1 Message Date
jamie 5b606404fc Merge pull request 'V2.1.0' (#57) from v2.1.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/57
2026-07-06 18:37:12 +01:00
jamie 8f135c8dd9 fix: 🐛 update SSO callback logic to handle error parameters and remove redundant 2FA checks
Release / SonarQube (pull_request) Successful in 26s
Release / Build & Release (pull_request) Successful in 29s
2026-07-06 18:36:51 +01:00
jamie d476901a53 feat: add SSO authentication support, configurable brand accent colors, and update UI theme 2026-07-06 18:20:01 +01:00
jamie 1346e9e5f5 docs: 📝 correct url 2026-05-30 21:43:29 +01:00
jamie af4f16aa59 docs: 📝 update readme 2026-05-30 21:42:28 +01:00
jamie e6ccba0e0a Merge pull request 'feat: move org name and logo to db' (#56) from v2.0.2 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/56
2026-05-30 15:33:54 +01:00
jamie 1a3d47a72e fix: 🐛 layout issue
Release / Build & Release (pull_request) Successful in 28s
Release / SonarQube (pull_request) Successful in 28s
2026-05-30 14:33:41 +00:00
jamie 6012566b22 feat: move org name and logo to db 2026-05-30 14:31:01 +00:00
20 changed files with 750 additions and 65 deletions
+1
View File
@@ -2,3 +2,4 @@ __pycache__
.env .env
frontend/node_modules/ frontend/node_modules/
static/dist/ static/dist/
venv/
+21
View File
@@ -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.
+62 -21
View File
@@ -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
![IPAM Dashboard](img/screenshot.png) ![IPAM Dashboard](img/screenshot.png)
## 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)
+219 -7
View File
@@ -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)
+96
View File
@@ -155,6 +155,13 @@ def init_db(app=None):
) )
''') ''')
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'")
if not cursor.fetchone(): if not cursor.fetchone():
@@ -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,
}
+27
View File
@@ -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
View File
@@ -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;
+7 -13
View File
@@ -1,12 +1,10 @@
<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";
const RELEASES_URL = "https://git.jdbnet.co.uk/jamie/ipam/releases";
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -27,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)),
@@ -101,16 +100,11 @@ onUnmounted(() => {
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="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 shrink-0 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>
<a <div class="text-xs text-slate-500">{{ auth.version }}</div>
:href="RELEASES_URL"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-slate-500 hover:text-accent hover:underline"
>{{ auth.version }}</a>
</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>
@@ -137,7 +131,7 @@ onUnmounted(() => {
<!-- Main --> <!-- Main -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<header class="flex shrink-0 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
+2
View File
@@ -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") },
], ],
}, },
+1 -1
View File
@@ -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: {
+9 -9
View File
@@ -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;
} }
} }
} }
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -36,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)));
@@ -128,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
+31 -2
View File
@@ -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>
+59
View File
@@ -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>
+77
View File
@@ -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>
+1 -1
View File
@@ -230,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>
+2
View File
@@ -4,3 +4,5 @@ dotenv
gunicorn gunicorn
pyotp pyotp
qrcode[pil] qrcode[pil]
Authlib
requests
+88
View File
@@ -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()
+17 -4
View File
@@ -1,6 +1,19 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "Building frontend..."
(cd frontend && npm ci && npm run build) echo "Building frontend UI..."
echo "Starting app..." cd frontend
python app.py npm install
npm run build
cd ..
echo "Setting up Python virtual environment..."
if [ ! -d "venv" ]; then
python3 -m venv venv
fi
source venv/bin/activate
pip install -r requirements.txt
echo "Starting server..."
python app.py serve