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['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
|
||||
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
|
||||
app.config['NAME'] = ''
|
||||
app.config['LOGO_PNG'] = ''
|
||||
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 ───────────────────────────────────────────────────────
|
||||
def generate_totp_secret():
|
||||
@@ -1260,17 +1263,18 @@ def api_auth_logout():
|
||||
|
||||
@app.route('/api/v2/auth/me', methods=['GET'])
|
||||
def api_auth_me():
|
||||
branding = org_branding()
|
||||
user = resolve_auth()
|
||||
if not user:
|
||||
return jsonify({
|
||||
'logged_in': False,
|
||||
'app_version': app.config['VERSION'],
|
||||
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']},
|
||||
'org': branding,
|
||||
})
|
||||
return jsonify({
|
||||
'logged_in': True,
|
||||
'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', '')},
|
||||
'permissions': sorted(user.get('permissions') or []),
|
||||
})
|
||||
@@ -3048,6 +3052,36 @@ def api_delete_role(role_id):
|
||||
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'])
|
||||
@require_permission('manage_roles')
|
||||
def api_permissions():
|
||||
@@ -3166,7 +3200,7 @@ DIST = os.path.join(STATIC_ROOT, 'dist')
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
logo = app.config['LOGO_PNG']
|
||||
logo = org_branding()['logo']
|
||||
if logo.startswith(('http://', 'https://')):
|
||||
return redirect(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 ───────────────────────────────────────────────────────────────
|
||||
init_db(app)
|
||||
load_org_settings(app)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
@@ -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
|
||||
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
|
||||
if not cursor.fetchone():
|
||||
@@ -363,6 +370,8 @@ def init_db(app=None):
|
||||
# Admin permissions
|
||||
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
||||
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
||||
('view_settings', 'View Settings page', 'Admin'),
|
||||
('manage_settings', 'Manage organisation settings', 'Admin'),
|
||||
]
|
||||
|
||||
# Insert permissions
|
||||
@@ -596,3 +605,80 @@ def run_v2_migrations(cursor, conn):
|
||||
logging.info("Removed orphaned permission: %s", perm_name)
|
||||
|
||||
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;
|
||||
},
|
||||
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) {
|
||||
const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
|
||||
return d.items;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
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 { api } from "@/api";
|
||||
|
||||
@@ -27,6 +27,7 @@ const nav = computed(() =>
|
||||
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
|
||||
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
|
||||
{ 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: "/account", label: "Account", icon: User, perm: null },
|
||||
].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: "admin", redirect: "/subnets/manage" },
|
||||
{ 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") },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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