feat: move org name and logo to db #56

Merged
jamie merged 2 commits from v2.0.2 into main 2026-05-30 15:33:54 +01:00
7 changed files with 294 additions and 7 deletions
Showing only changes of commit 6012566b22 - Show all commits
+41 -6
View File
@@ -37,11 +37,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():
@@ -1260,17 +1263,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 []),
}) })
@@ -3048,6 +3052,36 @@ 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'],
})
@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()
save_org_settings(current_app, name, logo)
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,
'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 +3200,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 +3234,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)
+86
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,80 @@ 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'
ORG_NAME_KEY = 'org_name'
ORG_LOGO_KEY = 'org_logo'
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()
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
conn.commit()
finally:
cursor.close()
conn.close()
def save_org_settings(app, name, logo):
conn = get_db_connection(app)
cursor = conn.cursor()
try:
set_setting(cursor, ORG_NAME_KEY, name)
set_setting(cursor, ORG_LOGO_KEY, logo)
conn.commit()
finally:
cursor.close()
conn.close()
app.config['NAME'] = name
app.config['LOGO_PNG'] = logo
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()
return {
'name': name or DEFAULT_ORG_NAME,
'logo': logo or DEFAULT_ORG_LOGO,
}
+8
View File
@@ -399,6 +399,14 @@ export const api = {
); );
return d.items; return d.items;
}, },
async settings() {
return handle<{ org_name: string; org_logo: string }>(await fetchApi("/api/v2/settings"));
},
async updateSettings(body: { org_name: string; org_logo: string }) {
return handle<{ org_name: string; org_logo: string; org?: { name: string; logo: 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;
+2 -1
View File
@@ -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";
@@ -27,6 +27,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)),
+1
View File
@@ -28,6 +28,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") },
], ],
}, },
+68
View File
@@ -0,0 +1,68 @@
<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: "" });
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 };
}
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 };
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 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>
+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()