11 Commits

Author SHA1 Message Date
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
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
13 changed files with 416 additions and 67 deletions
+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">
<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>
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
- **Subnet management** - CIDR subnets with automatic IP generation
- **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
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
Docker Compose file.
![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
services:
@@ -33,11 +52,33 @@ services:
ports:
- "5000:5000"
environment:
- MYSQL_HOST=10.10.2.27
- MYSQL_HOST=your_db_host
- MYSQL_USER=ipam
- MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=ipam
- SECRET_KEY=your_secret_key
- NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png
- SECRET_KEY=your_secret_key # generate with: openssl rand -hex 32
```
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)
+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['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)
+86
View File
@@ -154,6 +154,13 @@ def init_db(app=None):
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
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
@@ -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,
}
+8
View File
@@ -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;
+12 -11
View File
@@ -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";
@@ -25,6 +25,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)),
@@ -90,24 +91,24 @@ onUnmounted(() => {
</script>
<template>
<div class="flex min-h-screen bg-surface font-sans">
<div class="flex h-screen overflow-hidden bg-surface font-sans">
<!-- Mobile overlay -->
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
<!-- Sidebar -->
<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'"
>
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800">
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" />
<div class="min-w-0 flex-1">
<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-7 shrink-0 rounded" />
<div class="min-w-0 flex-1 leading-tight">
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
<div class="text-xs text-slate-500">{{ auth.version }}</div>
</div>
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
</div>
<nav class="flex-1 overflow-y-auto p-2">
<nav class="min-h-0 flex-1 overflow-y-auto p-2">
<RouterLink
v-for="item in nav"
:key="item.to"
@@ -122,15 +123,15 @@ onUnmounted(() => {
{{ item.label }}
</RouterLink>
</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>
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
</div>
</aside>
<!-- Main -->
<div class="flex 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">
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<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>
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
<button
@@ -141,7 +142,7 @@ onUnmounted(() => {
<Search class="h-5 w-5" />
</button>
</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 />
</main>
</div>
+1
View File
@@ -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") },
],
},
+3 -20
View File
@@ -10,7 +10,6 @@ interface DashboardStats {
available_ips: number;
utilization_percent: number;
subnet_count: number;
alerting_subnets: number;
device_count: number;
}
@@ -22,7 +21,6 @@ interface SubnetOverviewRow {
vlan_id?: number;
utilization: number;
available: number;
status: "active" | "alerting";
}
interface ActivityPoint {
@@ -93,10 +91,7 @@ function formatHour(h: number) {
<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="text-sm text-slate-500">
Total
<span v-if="stats.alerting_subnets" class="text-red-500">/ {{ stats.alerting_subnets }} alerting</span>
</div>
<div class="text-sm text-slate-500">Total</div>
</div>
</div>
<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">Available</th>
<th class="p-2">Site</th>
<th class="p-2">Status</th>
</tr>
</thead>
<tbody>
@@ -174,7 +168,6 @@ function formatHour(h: number) {
v-for="s in subnetOverview"
:key="s.id"
class="border-b border-slate-100 dark:border-slate-800"
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
>
<td class="p-2">
<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="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full"
:class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'"
class="h-full rounded-full bg-accent"
:style="{ width: `${s.utilization}%` }"
/>
</div>
@@ -194,18 +186,9 @@ function formatHour(h: number) {
</td>
<td class="p-2">{{ s.available }}</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 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>
</tbody>
</table>
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { ref, computed, watch } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router";
import { api, type Device, type Tag, type Subnet } from "@/api";
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) {
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>
</div>
<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">
<span>{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span>
<li
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>
<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="regenKey(u.id)">API key</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 -4
View File
@@ -1,8 +1,6 @@
#!/bin/bash
set -e
if [ ! -f static/dist/index.html ]; then
echo "Building frontend..."
(cd frontend && npm ci && npm run build)
fi
echo "Building frontend..."
(cd frontend && npm ci && npm run build)
echo "Starting app..."
python app.py