9 Commits

Author SHA1 Message Date
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
jamie fc5699a04c Merge pull request 'feat: version number links to releases' (#54) from v2.0.1 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/54
2026-05-27 07:54:15 +01:00
jamie 675d477ff9 fix: 🐛 users page layout
Release / Build & Release (pull_request) Successful in 9s
Release / SonarQube (pull_request) Successful in 28s
2026-05-27 06:53:47 +00:00
jamie 34856060e8 refactor: 🎨 lock nav in place while content scrolls 2026-05-27 06:49:45 +00:00
jamie be55503e1c refactor: 🎨 remove status and alerting from dashboard 2026-05-27 06:48:26 +00:00
jamie b79763be53 fix: 🐛 searching for another device didn't work if already looking at a device 2026-05-27 06:47:14 +00:00
jamie e961afc36a feat: version number links to releases 2026-05-27 06:45:16 +00:00
11 changed files with 333 additions and 46 deletions
+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;
+12 -11
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";
@@ -25,6 +25,7 @@ const nav = computed(() =>
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" }, { to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" }, { to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
{ to: "/users", label: "Users", icon: Users, perm: "view_users" }, { to: "/users", label: "Users", icon: Users, perm: "view_users" },
{ to: "/settings", label: "Settings", icon: SlidersHorizontal, perm: "view_settings" },
{ to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" }, { to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" },
{ to: "/account", label: "Account", icon: User, perm: null }, { to: "/account", label: "Account", icon: User, perm: null },
].filter((n) => !n.perm || auth.can(n.perm)), ].filter((n) => !n.perm || auth.can(n.perm)),
@@ -90,24 +91,24 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="flex min-h-screen bg-surface font-sans"> <div class="flex h-screen overflow-hidden bg-surface font-sans">
<!-- Mobile overlay --> <!-- Mobile overlay -->
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" /> <div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0" class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:h-screen lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800"> <div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-slate-200 px-4 dark:border-slate-800">
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" /> <img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-7 shrink-0 rounded" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1 leading-tight">
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div> <div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
<div class="text-xs text-slate-500">{{ auth.version }}</div> <div class="text-xs text-slate-500">{{ auth.version }}</div>
</div> </div>
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button> <button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
</div> </div>
<nav class="flex-1 overflow-y-auto p-2"> <nav class="min-h-0 flex-1 overflow-y-auto p-2">
<RouterLink <RouterLink
v-for="item in nav" v-for="item in nav"
:key="item.to" :key="item.to"
@@ -122,15 +123,15 @@ onUnmounted(() => {
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="border-t border-slate-200 p-3 dark:border-slate-800"> <div class="shrink-0 border-t border-slate-200 p-3 dark:border-slate-800">
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div> <div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button> <button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
</div> </div>
</aside> </aside>
<!-- Main --> <!-- Main -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800"> <header class="flex h-14 shrink-0 items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 dark:border-slate-800">
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button> <button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span> <span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
<button <button
@@ -141,7 +142,7 @@ onUnmounted(() => {
<Search class="h-5 w-5" /> <Search class="h-5 w-5" />
</button> </button>
</header> </header>
<main class="flex-1 overflow-auto p-4 md:p-6"> <main class="min-h-0 flex-1 overflow-auto p-4 md:p-6">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
+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") },
], ],
}, },
+3 -20
View File
@@ -10,7 +10,6 @@ interface DashboardStats {
available_ips: number; available_ips: number;
utilization_percent: number; utilization_percent: number;
subnet_count: number; subnet_count: number;
alerting_subnets: number;
device_count: number; device_count: number;
} }
@@ -22,7 +21,6 @@ interface SubnetOverviewRow {
vlan_id?: number; vlan_id?: number;
utilization: number; utilization: number;
available: number; available: number;
status: "active" | "alerting";
} }
interface ActivityPoint { interface ActivityPoint {
@@ -93,10 +91,7 @@ function formatHour(h: number) {
<div> <div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div> <div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div> <div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
<div class="text-sm text-slate-500"> <div class="text-sm text-slate-500">Total</div>
Total
<span v-if="stats.alerting_subnets" class="text-red-500">/ {{ stats.alerting_subnets }} alerting</span>
</div>
</div> </div>
</div> </div>
<div class="card flex items-start gap-4"> <div class="card flex items-start gap-4">
@@ -166,7 +161,6 @@ function formatHour(h: number) {
<th class="p-2">Utilised</th> <th class="p-2">Utilised</th>
<th class="p-2">Available</th> <th class="p-2">Available</th>
<th class="p-2">Site</th> <th class="p-2">Site</th>
<th class="p-2">Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -174,7 +168,6 @@ function formatHour(h: number) {
v-for="s in subnetOverview" v-for="s in subnetOverview"
:key="s.id" :key="s.id"
class="border-b border-slate-100 dark:border-slate-800" class="border-b border-slate-100 dark:border-slate-800"
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
> >
<td class="p-2"> <td class="p-2">
<RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink> <RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
@@ -184,8 +177,7 @@ function formatHour(h: number) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay"> <div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
<div <div
class="h-full rounded-full" class="h-full rounded-full bg-accent"
:class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization}%` }" :style="{ width: `${s.utilization}%` }"
/> />
</div> </div>
@@ -194,18 +186,9 @@ function formatHour(h: number) {
</td> </td>
<td class="p-2">{{ s.available }}</td> <td class="p-2">{{ s.available }}</td>
<td class="p-2">{{ s.site }}</td> <td class="p-2">{{ s.site }}</td>
<td class="p-2">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
:class="s.status === 'alerting' ? 'bg-red-500/15 text-red-500' : 'bg-accent/15 text-accent'"
>
<span class="h-1.5 w-1.5 rounded-full" :class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'" />
{{ s.status === 'alerting' ? 'Alerting' : 'Active' }}
</span>
</td>
</tr> </tr>
<tr v-if="!subnetOverview.length"> <tr v-if="!subnetOverview.length">
<td colspan="6" class="p-4 text-center text-slate-500">No subnets configured.</td> <td colspan="5" class="p-4 text-center text-slate-500">No subnets configured.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed, watch } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router"; import { useRoute, useRouter, RouterLink } from "vue-router";
import { api, type Device, type Tag, type Subnet } from "@/api"; import { api, type Device, type Tag, type Subnet } from "@/api";
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue"; import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
@@ -70,7 +70,15 @@ async function loadDevice() {
} }
} }
onMounted(loadDevice); watch(
() => route.params.id,
() => {
showAssignIp.value = false;
err.value = "";
loadDevice();
},
{ immediate: true },
);
async function loadAvailableIps(subnetId: number) { async function loadAvailableIps(subnetId: number) {
if (!subnetId) { if (!subnetId) {
+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>
+14 -3
View File
@@ -140,10 +140,21 @@ async function delRole(id: number) {
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button> <button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
</div> </div>
<ul class="space-y-2"> <ul class="space-y-2">
<li v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2"> <li
<span>{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span> class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span>User</span>
<span>Role</span>
<span v-if="auth.can('manage_users')" class="text-right">Actions</span>
</li>
<li
v-for="u in users"
:key="u.id"
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span class="min-w-0">{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span>
<span class="text-sm text-slate-500">{{ u.role_name }}</span> <span class="text-sm text-slate-500">{{ u.role_name }}</span>
<div v-if="auth.can('manage_users')" class="flex gap-2"> <div v-if="auth.can('manage_users')" class="flex gap-2 sm:justify-end">
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button> <button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button> <button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button> <button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
+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()
-2
View File
@@ -1,8 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
if [ ! -f static/dist/index.html ]; then
echo "Building frontend..." echo "Building frontend..."
(cd frontend && npm ci && npm run build) (cd frontend && npm ci && npm run build)
fi
echo "Starting app..." echo "Starting app..."
python app.py python app.py