feat: ✨ move org name and logo to db #56
@@ -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)
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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") },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
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()
|
||||||
Reference in New Issue
Block a user