feat: convert to api and rewrite ui

This commit is contained in:
2026-05-23 18:05:51 +00:00
parent e1dd5d1003
commit 31e417b9f5
112 changed files with 6585 additions and 8492 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IPAM</title>
<link rel="icon" href="/favicon.ico" type="image/png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-surface text-slate-900 dark:text-slate-100 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2671
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ipam-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+6
View File
@@ -0,0 +1,6 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>
+375
View File
@@ -0,0 +1,375 @@
const jsonHeaders = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) throw new Error("unauthorized");
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as T;
}
function fetchApi(path: string, init?: RequestInit) {
return fetch(path, { credentials: "include", ...init });
}
export interface MeResponse {
logged_in: boolean;
app_version?: string;
org?: { name: string; logo: string };
user?: { id: number; name: string; email: string };
permissions?: string[];
}
export interface Device {
id: number;
name: string;
description?: string;
ip_addresses?: IpOnDevice[];
tags?: Tag[];
custom_fields?: Record<string, unknown>;
}
export interface IpOnDevice {
id: number;
ip: string;
hostname?: string;
subnet_id?: number;
subnet_name?: string;
cidr?: string;
site?: string;
}
export interface Subnet {
id: number;
name: string;
cidr: string;
site?: string;
vlan_id?: number;
vlan_description?: string;
vlan_notes?: string;
utilization?: number;
total_ips?: number;
used_ips?: number;
custom_fields?: Record<string, unknown>;
ip_addresses?: SubnetIp[];
}
export interface SubnetIp {
id: number;
ip: string;
hostname?: string;
device_id?: number;
device_name?: string;
notes?: string;
}
export interface Tag {
id: number;
name: string;
color?: string;
description?: string;
}
export interface Rack {
id: number;
name: string;
site: string;
height_u: number;
used_u?: number;
percent_full?: number;
devices?: RackDevice[];
site_devices?: { id: number; name: string; description?: string }[];
}
export interface RackDevice {
id: number;
position_u: number;
side: string;
device_id?: number;
device_name?: string;
nonnet_device_name?: string;
}
export interface AuditEntry {
id: number;
user_name?: string;
action: string;
details?: string;
timestamp?: string;
}
export interface UserRow {
id: number;
name: string;
email: string;
role_id?: number;
role_name?: string;
}
export interface RoleRow {
id: number;
name: string;
description?: string;
require_2fa?: boolean;
permissions?: { id: number; name: string; category?: string }[];
}
export interface CustomFieldDef {
id: number;
entity_type: string;
name: string;
field_key: string;
field_type: string;
required?: boolean;
display_order?: number;
}
export const api = {
async me(): Promise<MeResponse> {
return handle(await fetchApi("/api/v2/auth/me"));
},
async login(email: string, password: string) {
return handle<{ ok?: boolean; requires_2fa?: boolean; requires_setup?: boolean }>(
await fetchApi("/api/v2/auth/login", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ email, password }),
}),
);
},
async verify2fa(code: string, useBackup = false) {
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ code, use_backup: useBackup }),
}));
},
async setup2fa(action: "generate" | "verify", code?: string) {
return handle<{ secret?: string; qr_code?: string; backup_codes?: string[] }>(
await fetchApi("/api/v2/auth/setup-2fa", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action, code }),
}),
);
},
async logout() {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
},
async dashboard() {
return handle<{ sites: Record<string, Subnet[]> }>(await fetchApi("/api/v2/dashboard"));
},
async search(q: string) {
return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`));
},
async devices(params?: { tag?: string; site?: string }) {
const p = new URLSearchParams();
if (params?.tag) p.set("tag", params.tag);
if (params?.site) p.set("site", params.site);
const q = p.toString();
const d = await handle<{ items: Device[] }>(await fetchApi(`/api/v2/devices${q ? `?${q}` : ""}`));
return d.items;
},
async device(id: number) {
return handle<Device>(await fetchApi(`/api/v2/devices/${id}`));
},
async createDevice(body: Partial<Device>) {
return handle(await fetchApi("/api/v2/devices", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateDevice(id: number, body: Partial<Device>) {
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteDevice(id: number) {
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "DELETE" }));
},
async assignIp(deviceId: number, ipId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ ip_id: ipId }),
}));
},
async removeIp(deviceId: number, ipId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips/${ipId}`, { method: "DELETE" }));
},
async deviceIpHistory(deviceId: number) {
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/devices/${deviceId}/ip-history`));
return d.items;
},
async subnets(includeUtil = true) {
const d = await handle<{ items: Subnet[] }>(
await fetchApi(`/api/v2/subnets${includeUtil ? "?include=utilization" : ""}`),
);
return d.items;
},
async subnet(id: number) {
return handle<Subnet>(await fetchApi(`/api/v2/subnets/${id}`));
},
async createSubnet(body: Partial<Subnet>) {
return handle(await fetchApi("/api/v2/subnets", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateSubnet(id: number, body: Partial<Subnet>) {
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteSubnet(id: number) {
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "DELETE" }));
},
async availableIps(subnetId: number) {
const d = await handle<{ items: { id: number; ip: string }[] }>(await fetchApi(`/api/v2/subnets/${subnetId}/available-ips`));
return d.items;
},
async patchIpNotes(ipId: number, notes: string) {
return handle(await fetchApi(`/api/v2/ip-addresses/${ipId}`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ notes }),
}));
},
async ipHistory(ip: string) {
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/ips/${encodeURIComponent(ip)}/history`));
return d.items;
},
subnetExportUrl(id: number) {
return `/api/v2/subnets/${id}/export`;
},
async tags() {
const d = await handle<{ tags?: Tag[]; items?: Tag[] }>(await fetchApi("/api/v2/tags"));
return d.items ?? d.tags ?? [];
},
async createTag(body: Partial<Tag>) {
return handle(await fetchApi("/api/v2/tags", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateTag(id: number, body: Partial<Tag>) {
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteTag(id: number) {
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "DELETE" }));
},
async assignTag(deviceId: number, tagId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ tag_id: tagId }),
}));
},
async removeTag(deviceId: number, tagId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags/${tagId}`, { method: "DELETE" }));
},
async racks() {
const d = await handle<{ racks?: Rack[]; items?: Rack[] }>(await fetchApi("/api/v2/racks"));
return d.racks ?? d.items ?? [];
},
async rack(id: number) {
return handle<Rack>(await fetchApi(`/api/v2/racks/${id}`));
},
async createRack(body: Partial<Rack>) {
return handle(await fetchApi("/api/v2/racks", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteRack(id: number) {
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "DELETE" }));
},
async updateRack(id: number, body: Partial<Rack>) {
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async addRackDevice(rackId: number, body: { position_u: number; side: string; device_id?: number; nonnet_device_name?: string }) {
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices`, { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async removeRackDevice(rackId: number, rackDeviceId: number) {
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices/${rackDeviceId}`, { method: "DELETE" }));
},
rackExportUrl(id: number) {
return `/api/v2/racks/${id}/export`;
},
async createCustomField(body: Record<string, unknown>) {
return handle(await fetchApi("/api/v2/custom_fields", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateCustomField(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteCustomField(id: number) {
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "DELETE" }));
},
async reorderCustomFields(entityType: string, fieldOrders: Record<number, number>) {
return handle(await fetchApi("/api/v2/custom-fields/reorder", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ entity_type: entityType, field_orders: fieldOrders }),
}));
},
async createUser(body: { name: string; email: string; password: string; role_id?: number }) {
return handle(await fetchApi("/api/v2/users", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateUser(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteUser(id: number) {
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "DELETE" }));
},
async regenerateApiKey(userId: number) {
return handle<{ api_key: string }>(await fetchApi(`/api/v2/users/${userId}/regenerate-api-key`, { method: "POST" }));
},
async createRole(body: { name: string; description?: string; permission_ids?: number[]; require_2fa?: boolean }) {
return handle(await fetchApi("/api/v2/roles", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateRole(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteRole(id: number) {
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "DELETE" }));
},
async disable2fa(password: string) {
return handle(await fetchApi("/api/v2/account/disable-2fa", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
}));
},
async regenerateBackupCodes(password: string) {
return handle<{ backup_codes: string[] }>(await fetchApi("/api/v2/account/regenerate-backup-codes", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
}));
},
async audit(limit = 100) {
const d = await handle<{ logs: AuditEntry[] }>(await fetchApi(`/api/v2/audit?limit=${limit}`));
return d.logs;
},
auditExportUrl: "/api/v2/audit/export",
async users() {
const d = await handle<{ users?: UserRow[]; items?: UserRow[] }>(await fetchApi("/api/v2/users"));
return d.users ?? d.items ?? [];
},
async roles() {
const d = await handle<{ roles?: RoleRow[] }>(await fetchApi("/api/v2/roles"));
return d.roles ?? [];
},
async permissions() {
const d = await handle<{ items: { id: number; name: string; category?: string }[] }>(
await fetchApi("/api/v2/permissions"),
);
return d.items;
},
async customFields(entityType: string) {
const d = await handle<{ fields?: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
return d.fields ?? [];
},
async bulkAssignIps(deviceId: number, ipIds: number[]) {
return handle(await fetchApi("/api/v2/bulk/assign-ips", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_id: deviceId, ip_ids: ipIds }),
}));
},
async bulkCreateDevices(names: string[]) {
return handle(await fetchApi("/api/v2/bulk/create-devices", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ names }),
}));
},
async bulkAssignTags(deviceIds: number[], tagId: number) {
return handle(await fetchApi("/api/v2/bulk/assign-tags", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_ids: deviceIds, tag_id: tagId }),
}));
},
async account() {
return handle(await fetchApi("/api/v2/account"));
},
async changePassword(current: string, newPw: string) {
return handle(await fetchApi("/api/v2/account/change-password", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ current_password: current, new_password: newPw }),
}));
},
async getDhcp(subnetId: number) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`));
},
async setDhcp(subnetId: number, body: unknown) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify(body),
}));
},
};
+216
View File
@@ -0,0 +1,216 @@
<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 } from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const sidebarOpen = ref(false);
const searchOpen = ref(false);
const searchQ = ref("");
const searchInput = ref<HTMLInputElement | null>(null);
const searchResults = ref<Record<string, unknown[]>>({});
const searchLoading = ref(false);
const nav = computed(() =>
[
{ to: "/", label: "Home", icon: Home, perm: "view_index" },
{ to: "/devices", label: "Devices", icon: Server, perm: "view_devices" },
{ to: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" },
{ to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" },
{ 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: "/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)),
);
const hasResults = computed(() =>
Object.values(searchResults.value).some((items) => items.length > 0),
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function logout() {
await auth.logout();
router.push("/login");
}
function openSearch() {
searchOpen.value = true;
searchQ.value = "";
searchResults.value = {};
nextTick(() => searchInput.value?.focus());
}
function closeSearch() {
searchOpen.value = false;
}
async function runSearch() {
const q = searchQ.value.trim();
if (!q) {
searchResults.value = {};
return;
}
searchLoading.value = true;
try {
searchResults.value = await api.search(q);
} finally {
searchLoading.value = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
e.preventDefault();
openSearch();
}
if (e.key === "Escape" && searchOpen.value) {
closeSearch();
}
}
watch(searchQ, () => {
if (!searchOpen.value) return;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => {
window.removeEventListener("keydown", onKeydown);
if (searchTimer) clearTimeout(searchTimer);
});
</script>
<template>
<div class="flex min-h-screen 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="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="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">
<RouterLink
v-for="item in nav"
:key="item.to"
:to="item.to"
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
:class="route.path === item.to || route.path.startsWith(item.to + '/')
? 'bg-accent/15 text-accent font-medium'
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
@click="sidebarOpen = false"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<div class="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">
<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
class="ml-auto rounded-lg p-2 text-slate-600 transition hover:bg-surface-overlay hover:text-accent dark:text-slate-400"
title="Search (/)"
@click="openSearch"
>
<Search class="h-5 w-5" />
</button>
</header>
<main class="flex-1 overflow-auto p-4 md:p-6">
<RouterView />
</main>
</div>
<!-- Search modal -->
<div v-if="searchOpen" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[10vh]" @click.self="closeSearch">
<div class="card flex max-h-[75vh] w-full max-w-xl flex-col">
<div class="flex items-center gap-2">
<Search class="h-5 w-5 shrink-0 text-slate-400" />
<input
ref="searchInput"
v-model="searchQ"
class="input-field flex-1 border-0 bg-transparent px-0 shadow-none focus:ring-0"
placeholder="Search subnets, IPs, devices…"
autofocus
@keydown.esc="closeSearch"
/>
<button class="rounded-lg p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200" @click="closeSearch">
<X class="h-5 w-5" />
</button>
</div>
<p class="mt-1 text-xs text-slate-500">Press <kbd class="rounded bg-surface-overlay px-1">/</kbd> to open · <kbd class="rounded bg-surface-overlay px-1">Esc</kbd> to close</p>
<div v-if="searchLoading" class="mt-4 text-sm text-slate-500">Searching</div>
<div v-else-if="searchQ.trim() && !hasResults" class="mt-4 text-sm text-slate-500">No results</div>
<div v-else-if="hasResults" class="mt-4 -mx-1 flex-1 space-y-4 overflow-y-auto px-1">
<section v-if="searchResults.devices?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<ul class="mt-1">
<li v-for="d in searchResults.devices as { id: number; name: string }[]" :key="d.id">
<RouterLink :to="`/devices/${d.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ d.name }}</RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.subnets?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subnets</h2>
<ul class="mt-1">
<li v-for="s in searchResults.subnets as { id: number; name: string; cidr: string }[]" :key="s.id">
<RouterLink :to="`/subnets/${s.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ s.name }} <span class="font-mono text-slate-500">({{ s.cidr }})</span></RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.ips?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">IPs</h2>
<ul class="mt-1">
<li v-for="ip in searchResults.ips as { ip: string; subnet_id: number; hostname?: string }[]" :key="ip.ip">
<RouterLink :to="`/subnets/${ip.subnet_id}`" class="block rounded-lg px-2 py-1.5 font-mono text-sm hover:bg-surface-overlay" @click="closeSearch">
{{ ip.ip }}<span v-if="ip.hostname" class="ml-2 font-sans text-slate-500">{{ ip.hostname }}</span>
</RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.racks?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Racks</h2>
<ul class="mt-1">
<li v-for="r in searchResults.racks as { id: number; name: string; site: string }[]" :key="r.id">
<RouterLink :to="`/racks/${r.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ r.name }} <span class="text-slate-500">· {{ r.site }}</span></RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.tags?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tags</h2>
<ul class="mt-1">
<li v-for="t in searchResults.tags as { id: number; name: string }[]" :key="t.id">
<RouterLink to="/tags" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ t.name }}</RouterLink>
</li>
</ul>
</section>
</div>
</div>
</div>
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { X } from "lucide-vue-next";
import { api } from "@/api";
import { formatLocalTime } from "@/utils/datetime";
export interface IpHistoryEntry {
ip: string;
action: "assigned" | "removed";
device_name: string;
subnet_name?: string;
subnet_cidr?: string;
user_name?: string;
timestamp?: string;
}
const props = defineProps<{
ip: string | null;
}>();
const emit = defineEmits<{ close: [] }>();
const loading = ref(false);
const error = ref("");
const history = ref<IpHistoryEntry[]>([]);
watch(
() => props.ip,
async (ip) => {
if (!ip) {
history.value = [];
error.value = "";
return;
}
loading.value = true;
error.value = "";
try {
history.value = (await api.ipHistory(ip)) as IpHistoryEntry[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load history";
history.value = [];
} finally {
loading.value = false;
}
},
{ immediate: true },
);
function formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown");
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
}
</script>
<template>
<Teleport to="body">
<div
v-if="ip"
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
@click.self="emit('close')"
@keydown="onKeydown"
>
<div class="card max-h-[80vh] w-full max-w-lg overflow-hidden p-0 shadow-xl">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<h2 class="font-semibold">IP history · <span class="font-mono text-accent">{{ ip }}</span></h2>
<button type="button" class="rounded-lg p-1 hover:bg-surface-overlay" aria-label="Close" @click="emit('close')">
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[60vh] overflow-y-auto p-4">
<p v-if="loading" class="text-center text-sm text-slate-500">Loading</p>
<p v-else-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
<p v-else-if="history.length === 0" class="text-center text-sm text-slate-500">No assignment history for this address.</p>
<ul v-else class="space-y-3">
<li
v-for="(entry, i) in history"
:key="i"
class="flex gap-3 border-b border-slate-100 pb-3 last:border-0 dark:border-slate-800"
>
<span
class="mt-0.5 shrink-0 text-xs font-semibold uppercase"
:class="entry.action === 'assigned' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'"
>
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span>
<div class="min-w-0 flex-1 text-sm">
<div>
<span class="font-medium">{{ entry.device_name }}</span>
<span v-if="entry.subnet_name" class="text-slate-500">
· {{ entry.subnet_name }}<span v-if="entry.subnet_cidr"> ({{ entry.subnet_cidr }})</span>
</span>
</div>
<div class="mt-1 text-xs text-slate-500">
{{ entry.user_name || "Unknown" }} · {{ formatTime(entry.timestamp) }}
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</Teleport>
</template>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./style.css";
createApp(App).use(createPinia()).use(router).mount("#app");
+44
View File
@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
{
path: "/",
component: () => import("@/components/AppLayout.vue"),
children: [
{ path: "", name: "dashboard", component: () => import("@/views/DashboardView.vue") },
{ path: "devices", name: "devices", component: () => import("@/views/DevicesView.vue") },
{ path: "devices/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") },
{ path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") },
{ path: "subnets/:id/dhcp", name: "dhcp", component: () => import("@/views/DhcpView.vue") },
{ path: "racks", name: "racks", component: () => import("@/views/RacksView.vue") },
{ path: "racks/:id", name: "rack", component: () => import("@/views/RackDetailView.vue") },
{ path: "search", redirect: "/" },
{ path: "tags", name: "tags", component: () => import("@/views/TagsView.vue") },
{ path: "device-types", redirect: "/devices" },
{ path: "custom-fields", name: "custom-fields", component: () => import("@/views/CustomFieldsView.vue") },
{ path: "bulk", redirect: "/devices" },
{ path: "audit", name: "audit", component: () => import("@/views/AuditView.vue") },
{ 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: "account", name: "account", component: () => import("@/views/AccountView.vue") },
],
},
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.loaded) await auth.fetchMe().catch(() => {});
if (to.meta.public) return true;
if (!auth.loggedIn) return { name: "login", query: { redirect: to.fullPath } };
return true;
});
export default router;
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { api, type MeResponse } from "@/api";
export const useAuthStore = defineStore("auth", {
state: () => ({
loaded: false,
loggedIn: false,
user: null as MeResponse["user"] | null,
permissions: [] as string[],
org: { name: "IPAM", logo: "" },
version: "unknown",
}),
getters: {
can: (state) => (perm: string) => state.permissions.includes(perm),
},
actions: {
async fetchMe() {
const data = await api.me();
this.loaded = true;
this.loggedIn = data.logged_in;
this.user = data.user ?? null;
this.permissions = data.permissions ?? [];
this.org = data.org ?? this.org;
this.version = data.app_version ?? "unknown";
},
async login(email: string, password: string) {
return api.login(email, password);
},
async logout() {
await api.logout();
this.loggedIn = false;
this.user = null;
this.permissions = [];
},
},
});
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--surface: 248 250 252;
--surface-raised: 255 255 255;
--surface-overlay: 241 245 249;
--accent: 6 182 212;
--accent-muted: 8 145 178;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: 15 20 25;
--surface-raised: 21 28 36;
--surface-overlay: 26 35 46;
--accent: 34 211 238;
--accent-muted: 6 182 212;
}
}
}
@layer components {
.btn-primary {
@apply rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950 transition hover:opacity-90 disabled:opacity-50;
}
.btn-secondary {
@apply rounded-lg border border-slate-300 bg-surface-raised px-4 py-2 text-sm font-medium transition hover:bg-surface-overlay dark:border-slate-700;
}
.input-field {
@apply w-full rounded-lg border border-slate-300 bg-surface-overlay px-3 py-2 text-sm outline-none ring-accent focus:border-accent focus:ring-1 dark:border-slate-700;
}
.card {
@apply rounded-xl border border-slate-200 bg-surface-raised p-4 shadow-sm dark:border-slate-800;
}
}
+23
View File
@@ -0,0 +1,23 @@
/** Parse API timestamps (GMT strings, ISO, or naive UTC) for local display. */
export function parseApiTimestamp(ts?: string | null): Date | null {
if (!ts) return null;
const trimmed = ts.trim();
if (!trimmed) return null;
// RFC/GMT strings from Flask/MySQL — parse as-is
if (/GMT|Z|[+-]\d{2}:\d{2}$/.test(trimmed)) {
const d = new Date(trimmed);
if (!Number.isNaN(d.getTime())) return d;
}
// Naive datetime — treat as UTC
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
const d = new Date(normalized.endsWith("Z") ? normalized : `${normalized}Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatLocalTime(ts?: string | null, fallback = "—"): string {
const d = parseApiTimestamp(ts);
if (!d) return ts?.trim() || fallback;
return d.toLocaleString();
}
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const profile = ref<{
totp_enabled?: boolean;
role_requires_2fa?: boolean;
backup_codes?: string[];
} | null>(null);
const pw = ref({ current: "", newPw: "" });
const mfaPw = ref("");
const msg = ref("");
const err = ref("");
const newBackupCodes = ref<string[]>([]);
onMounted(async () => { profile.value = await api.account() as typeof profile.value; });
async function changePw() {
err.value = "";
try {
await api.changePassword(pw.value.current, pw.value.newPw);
msg.value = "Password updated";
pw.value = { current: "", newPw: "" };
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function disable2fa() {
if (!mfaPw.value || !confirm("Disable two-factor authentication?")) return;
err.value = "";
try {
await api.disable2fa(mfaPw.value);
mfaPw.value = "";
profile.value = await api.account() as typeof profile.value;
msg.value = "2FA disabled";
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function regenCodes() {
if (!mfaPw.value || !confirm("Regenerate backup codes? Old codes will stop working.")) return;
err.value = "";
try {
const r = await api.regenerateBackupCodes(mfaPw.value);
newBackupCodes.value = r.backup_codes;
mfaPw.value = "";
profile.value = await api.account() as typeof profile.value;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Account</h1>
<div class="card mt-6 max-w-md space-y-2">
<p><strong>{{ auth.user?.name }}</strong></p>
<p class="text-slate-500">{{ auth.user?.email }}</p>
<p class="text-sm">2FA: {{ profile?.totp_enabled ? "Enabled" : "Disabled" }}</p>
</div>
<div class="card mt-6 max-w-md space-y-4">
<h2 class="font-semibold">Two-factor authentication</h2>
<template v-if="profile?.totp_enabled">
<div v-if="profile.backup_codes?.length">
<p class="text-sm text-slate-500">Backup codes:</p>
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in profile.backup_codes" :key="c">{{ c }}</li>
</ul>
</div>
<div v-if="newBackupCodes.length">
<p class="text-sm font-medium text-accent">New backup codes save these now:</p>
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
</ul>
</div>
<input v-model="mfaPw" type="password" class="input-field" placeholder="Password to confirm" />
<div class="flex flex-wrap gap-2">
<button class="btn-secondary text-sm" @click="regenCodes">Regenerate backup codes</button>
<button
v-if="!profile.role_requires_2fa"
class="text-sm text-red-500 hover:underline"
@click="disable2fa"
>Disable 2FA</button>
<p v-else class="text-sm text-slate-500">Your role requires 2FA it cannot be disabled.</p>
</div>
</template>
<template v-else>
<p class="text-sm text-slate-500">Protect your account with an authenticator app.</p>
<RouterLink to="/setup-2fa" class="btn-primary inline-block text-sm">Enable 2FA</RouterLink>
</template>
</div>
<form class="card mt-6 max-w-md space-y-3" @submit.prevent="changePw">
<h2 class="font-semibold">Change password</h2>
<input v-model="pw.current" type="password" class="input-field" placeholder="Current password" />
<input v-model="pw.newPw" type="password" class="input-field" placeholder="New password" />
<button class="btn-primary">Update</button>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</form>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type AuditEntry } from "@/api";
import { formatLocalTime } from "@/utils/datetime";
const logs = ref<AuditEntry[]>([]);
onMounted(async () => { logs.value = await api.audit(200); });
</script>
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Audit log</h1>
<a href="/api/v2/audit/export" class="btn-secondary text-sm">Export CSV</a>
</div>
<div class="card mt-6 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead><tr class="border-b dark:border-slate-700"><th class="p-2">Time</th><th class="p-2">User</th><th class="p-2">Action</th><th class="p-2">Details</th></tr></thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800">
<td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td>
<td class="p-2">{{ l.user_name }}</td>
<td class="p-2 font-mono text-xs">{{ l.action }}</td>
<td class="p-2 max-w-md truncate">{{ l.details }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type CustomFieldDef } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tab = ref<"device" | "subnet">("device");
const fields = ref<CustomFieldDef[]>([]);
const form = ref({ name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
const editForm = ref({ id: 0, name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
const showAdd = ref(false);
const showEdit = ref(false);
const err = ref("");
const fieldTypes = ["text", "textarea", "number", "select", "checkbox", "date"];
async function load() {
fields.value = await api.customFields(tab.value);
}
onMounted(load);
async function create() {
err.value = "";
try {
await api.createCustomField({ ...form.value, entity_type: tab.value });
showAdd.value = false;
form.value = { name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(f: CustomFieldDef) {
editForm.value = {
id: f.id,
name: f.name,
field_key: f.field_key,
field_type: f.field_type,
required: !!f.required,
default_value: "",
help_text: "",
};
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateCustomField(editForm.value.id, {
name: editForm.value.name,
field_type: editForm.value.field_type,
required: editForm.value.required,
default_value: editForm.value.default_value || null,
help_text: editForm.value.help_text || null,
});
showEdit.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete this custom field?")) return;
await api.deleteCustomField(id);
await load();
}
async function moveField(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= fields.value.length) return;
const reordered = [...fields.value];
const [item] = reordered.splice(index, 1);
reordered.splice(target, 0, item);
const orders: Record<number, number> = {};
reordered.forEach((f, i) => { orders[f.id] = i; });
await api.reorderCustomFields(tab.value, orders);
fields.value = reordered;
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Custom fields</h1>
<div class="mt-4 flex flex-wrap items-center gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'device' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'device'; load()">Device</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'subnet' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'subnet'; load()">Subnet</button>
<button v-if="auth.can('manage_custom_fields')" class="btn-primary ml-auto text-sm" @click="showAdd = true; err = ''">Add field</button>
</div>
<ul class="mt-6 space-y-2">
<li v-for="(f, i) in fields" :key="f.id" class="card flex flex-wrap items-center justify-between gap-2">
<span>{{ f.name }} <span class="text-slate-500">({{ f.field_type }})</span></span>
<span class="font-mono text-xs text-slate-500">{{ f.field_key }}</span>
<div v-if="auth.can('manage_custom_fields')" class="flex gap-2">
<button class="text-sm text-slate-500 hover:underline" :disabled="i === 0" @click="moveField(i, -1)"></button>
<button class="text-sm text-slate-500 hover:underline" :disabled="i === fields.length - 1" @click="moveField(i, 1)"></button>
<button class="text-sm text-accent hover:underline" @click="openEdit(f)">Edit</button>
<button class="text-sm text-red-500 hover:underline" @click="del(f.id)">Delete</button>
</div>
</li>
</ul>
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="create">
<h2 class="text-lg font-semibold">Add custom field</h2>
<input v-model="form.name" class="input-field" placeholder="Display name" required />
<input v-model="form.field_key" class="input-field font-mono text-sm" placeholder="field_key" required />
<select v-model="form.field_type" class="input-field">
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
</select>
<label class="flex items-center gap-2 text-sm"><input v-model="form.required" type="checkbox" /> Required</label>
<input v-model="form.default_value" class="input-field" placeholder="Default value" />
<input v-model="form.help_text" class="input-field" placeholder="Help text" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit custom field</h2>
<input v-model="editForm.name" class="input-field" required />
<input v-model="editForm.field_key" class="input-field font-mono text-sm" disabled />
<select v-model="editForm.field_type" class="input-field">
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
</select>
<label class="flex items-center gap-2 text-sm"><input v-model="editForm.required" type="checkbox" /> Required</label>
<input v-model="editForm.default_value" class="input-field" placeholder="Default value" />
<input v-model="editForm.help_text" class="input-field" placeholder="Help text" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const sites = ref<Record<string, Subnet[]>>({});
const loading = ref(true);
onMounted(async () => {
try {
const d = await api.dashboard();
sites.value = d.sites;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Subnets grouped by site</p>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-8">
<section v-for="(subnets, site) in sites" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in subnets"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref, onMounted, computed } 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";
import { useAuthStore } from "@/stores/auth";
import { formatLocalTime } from "@/utils/datetime";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const device = ref<Device | null>(null);
const allTags = ref<Tag[]>([]);
const subnets = ref<Subnet[]>([]);
const availableIps = ref<{ id: number; ip: string }[]>([]);
const history = ref<IpHistoryEntry[]>([]);
const editName = ref("");
const saving = ref(false);
const showAssignIp = ref(false);
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
const err = ref("");
const sites = computed(() => {
const list = [...new Set(subnets.value.map((s) => s.site || "Unassigned"))];
return list.sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
});
});
const deviceSites = computed(() =>
[...new Set((device.value?.ip_addresses ?? []).map((ip) => ip.site || "Unassigned"))],
);
const assignableSites = computed(() =>
deviceSites.value.length ? sites.value.filter((s) => deviceSites.value.includes(s)) : sites.value,
);
const subnetsForSite = computed(() =>
subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site),
);
onMounted(async () => {
const id = Number(route.params.id);
const [d, tags, h, sn] = await Promise.all([
api.device(id),
api.tags(),
api.deviceIpHistory(id).catch(() => []),
api.subnets(false),
]);
device.value = d;
editName.value = d.name;
allTags.value = tags;
subnets.value = sn;
history.value = h as IpHistoryEntry[];
});
async function loadAvailableIps(subnetId: number) {
if (!subnetId) {
availableIps.value = [];
assignForm.value.ip_id = 0;
return;
}
availableIps.value = await api.availableIps(subnetId);
assignForm.value.ip_id = availableIps.value[0]?.id ?? 0;
}
async function onSiteChange() {
const list = subnetsForSite.value;
assignForm.value.subnet_id = list[0]?.id ?? 0;
await loadAvailableIps(assignForm.value.subnet_id);
}
async function onSubnetChange() {
await loadAvailableIps(assignForm.value.subnet_id);
}
async function openAssignIpModal() {
err.value = "";
const defaultSite = assignableSites.value[0] ?? sites.value[0] ?? "";
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
assignForm.value = {
site: defaultSite,
subnet_id: defaultSubnet?.id ?? 0,
ip_id: 0,
};
if (assignForm.value.subnet_id) await loadAvailableIps(assignForm.value.subnet_id);
showAssignIp.value = true;
}
async function saveName() {
if (!device.value) return;
saving.value = true;
await api.updateDevice(device.value.id, { name: editName.value });
device.value.name = editName.value;
saving.value = false;
}
async function assignTag(tagId: number) {
if (!device.value || !tagId) return;
await api.assignTag(device.value.id, tagId);
device.value = await api.device(device.value.id);
}
async function removeTag(tagId: number) {
if (!device.value || !confirm("Remove this tag?")) return;
await api.removeTag(device.value.id, tagId);
device.value = await api.device(device.value.id);
}
async function assignIp() {
if (!device.value || !assignForm.value.ip_id) return;
err.value = "";
try {
await api.assignIp(device.value.id, assignForm.value.ip_id);
showAssignIp.value = false;
device.value = await api.device(device.value.id);
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function removeIp(ipId: number) {
if (!device.value || !confirm("Remove this IP from the device?")) return;
await api.removeIp(device.value.id, ipId);
device.value = await api.device(device.value.id);
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
}
async function deleteDevice() {
if (!device.value || !confirm(`Delete device "${device.value.name}"? This cannot be undone.`)) return;
await api.deleteDevice(device.value.id);
router.push("/devices");
}
function formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown");
}
</script>
<template>
<div v-if="device">
<RouterLink to="/devices" class="text-sm text-accent hover:underline"> Devices</RouterLink>
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
<div class="flex-1">
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" />
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1>
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p>
</div>
<button
v-if="auth.can('delete_device')"
class="text-sm text-red-500 hover:underline"
@click="deleteDevice"
>Delete device</button>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<div class="flex items-center justify-between">
<h2 class="font-semibold">IP addresses</h2>
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
</div>
<ul class="mt-3 space-y-2">
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between font-mono text-sm">
<span>{{ ip.ip }} <span class="text-slate-500">({{ ip.subnet_name }})</span></span>
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
</li>
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
</ul>
</div>
<div class="card">
<h2 class="font-semibold">Tags</h2>
<div class="mt-2 flex flex-wrap gap-2">
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
{{ t.name }}
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
</span>
</div>
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
<option value="">Add tag</option>
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div class="card lg:col-span-2">
<h2 class="font-semibold">IP history</h2>
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
<ul v-else class="mt-3 space-y-3">
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
<span class="shrink-0 font-semibold uppercase text-xs" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span>
<span class="font-mono">{{ entry.ip }}</span>
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
</li>
</ul>
</div>
</div>
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
<h2 class="text-lg font-semibold">Assign IP</h2>
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="assignForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Device, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const devices = ref<Device[]>([]);
const tagFilter = ref("");
const tags = ref<string[]>([]);
const subnets = ref<Subnet[]>([]);
const availableIps = ref<{ id: number; ip: string }[]>([]);
const loading = ref(true);
const showAdd = ref(false);
const showBulk = ref(false);
const assignIpOnCreate = ref(false);
const addForm = ref({ name: "", description: "", site: "", subnet_id: 0, ip_id: 0 });
const bulkForm = ref({ names: "" });
const err = ref("");
const sites = computed(() =>
[...new Set(subnets.value.map((s) => s.site || "Unassigned"))].sort(),
);
const subnetsForSite = computed(() =>
subnets.value.filter((s) => (s.site || "Unassigned") === addForm.value.site),
);
const bySite = computed(() => {
const m: Record<string, Device[]> = {};
for (const d of devices.value) {
const site = d.ip_addresses?.[0]?.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(d);
}
return m;
});
const siteOrder = computed(() =>
Object.keys(bySite.value).sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
}),
);
async function loadDevices() {
loading.value = true;
devices.value = await api.devices({ tag: tagFilter.value || undefined });
loading.value = false;
}
onMounted(async () => {
const [tagList, sn] = await Promise.all([api.tags(), api.subnets(false)]);
tags.value = tagList.map((t) => t.name);
subnets.value = sn;
if (sn.length) {
addForm.value.site = sn[0].site || "Unassigned";
addForm.value.subnet_id = sn[0].id;
}
await loadDevices();
});
async function loadAvailableIps(subnetId: number) {
if (!subnetId) {
availableIps.value = [];
addForm.value.ip_id = 0;
return;
}
availableIps.value = await api.availableIps(subnetId);
addForm.value.ip_id = availableIps.value[0]?.id ?? 0;
}
async function onAddSiteChange() {
const list = subnetsForSite.value;
addForm.value.subnet_id = list[0]?.id ?? 0;
await loadAvailableIps(addForm.value.subnet_id);
}
async function onAddSubnetChange() {
await loadAvailableIps(addForm.value.subnet_id);
}
async function onAssignIpToggle() {
if (assignIpOnCreate.value) {
if (!addForm.value.site) addForm.value.site = sites.value[0] ?? "";
await onAddSiteChange();
} else {
availableIps.value = [];
addForm.value.ip_id = 0;
}
}
async function openAddModal() {
err.value = "";
assignIpOnCreate.value = false;
availableIps.value = [];
const defaultSite = sites.value[0] ?? "";
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
addForm.value = {
name: "",
description: "",
site: defaultSite,
subnet_id: defaultSubnet?.id ?? 0,
ip_id: 0,
};
showAdd.value = true;
}
async function filterTag(t: string) {
tagFilter.value = t;
await loadDevices();
}
async function createDevice() {
err.value = "";
try {
const created = await api.createDevice({
name: addForm.value.name,
description: addForm.value.description,
}) as { id: number };
if (assignIpOnCreate.value) {
if (!addForm.value.ip_id) {
err.value = "Select an IP address or uncheck “Assign an IP address”";
return;
}
if (auth.can("add_device_ip")) {
await api.assignIp(created.id, addForm.value.ip_id);
}
}
showAdd.value = false;
assignIpOnCreate.value = false;
availableIps.value = [];
addForm.value = {
name: "",
description: "",
site: sites.value[0] ?? "",
subnet_id: subnets.value[0]?.id ?? 0,
ip_id: 0,
};
await loadDevices();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function bulkCreate() {
err.value = "";
const names = bulkForm.value.names.split("\n").map((n) => n.trim()).filter(Boolean);
if (!names.length) {
err.value = "Enter at least one device name";
return;
}
try {
await api.bulkCreateDevices(names);
showBulk.value = false;
bulkForm.value.names = "";
await loadDevices();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Devices</h1>
<div v-if="auth.can('add_device')" class="flex gap-2">
<button class="btn-primary text-sm" @click="openAddModal">Add device</button>
<button class="btn-secondary text-sm" @click="showBulk = true; err = ''">Bulk add</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button class="rounded-full px-3 py-1 text-xs" :class="!tagFilter ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag('')">All</button>
<button v-for="t in tags" :key="t" class="rounded-full px-3 py-1 text-xs" :class="tagFilter === t ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag(t)">{{ t }}</button>
</div>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-6">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-2 font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink v-for="d in bySite[site]" :key="d.id" :to="`/devices/${d.id}`" class="card flex items-center gap-3 py-3 transition hover:border-accent/50">
<div class="min-w-0 flex-1">
<div class="truncate font-medium">{{ d.name }}</div>
<div class="truncate text-xs text-slate-500">{{ d.ip_addresses?.map((i) => i.ip).join(", ") || "No IPs" }}</div>
</div>
</RouterLink>
</div>
</section>
</div>
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="createDevice">
<h2 class="text-lg font-semibold">Add device</h2>
<input v-model="addForm.name" class="input-field" placeholder="Name" required />
<input v-model="addForm.description" class="input-field" placeholder="Description" />
<template v-if="auth.can('add_device_ip') && subnets.length">
<label class="flex items-center gap-2 text-sm">
<input v-model="assignIpOnCreate" type="checkbox" @change="onAssignIpToggle" />
Assign an IP address
</label>
<template v-if="assignIpOnCreate">
<select v-model="addForm.site" class="input-field" @change="onAddSiteChange">
<option v-for="site in sites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="addForm.subnet_id" class="input-field" @change="onAddSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="addForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="addForm.subnet_id && !availableIps.length" class="text-xs text-slate-500">No available IPs in this subnet</p>
</template>
</template>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showBulk" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showBulk = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="bulkCreate">
<h2 class="text-lg font-semibold">Bulk add devices</h2>
<textarea v-model="bulkForm.names" class="input-field h-32" placeholder="One device name per line" required />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showBulk = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api } from "@/api";
const route = useRoute();
const pool = ref<{ start_ip?: string; end_ip?: string; excluded_ips?: string } | null>(null);
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
const msg = ref("");
onMounted(async () => {
try {
const d = await api.getDhcp(Number(route.params.id)) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
if (d.pools?.[0]) {
pool.value = d.pools[0];
form.value.start_ip = d.pools[0].start_ip;
form.value.end_ip = d.pools[0].end_ip;
form.value.excluded_ips = d.pools[0].excluded_ips || "";
}
} catch { /* no pool */ }
});
async function save() {
await api.setDhcp(Number(route.params.id), {
pools: [{ start_ip: form.value.start_ip, end_ip: form.value.end_ip, excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean) }],
});
msg.value = "Saved";
}
async function remove() {
await api.setDhcp(Number(route.params.id), { remove: true });
pool.value = null;
msg.value = "Removed";
}
</script>
<template>
<div>
<RouterLink :to="`/subnets/${route.params.id}`" class="text-sm text-accent hover:underline"> Subnet</RouterLink>
<h1 class="mt-4 text-2xl font-bold">DHCP pool</h1>
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
<input v-model="form.start_ip" class="input-field" placeholder="Start IP" required />
<input v-model="form.end_ip" class="input-field" placeholder="End IP" required />
<input v-model="form.excluded_ips" class="input-field" placeholder="Excluded IPs (comma-separated)" />
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button v-if="pool" type="button" class="btn-secondary" @click="remove">Remove pool</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
</form>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const email = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
async function submit() {
err.value = "";
busy.value = true;
try {
const r = await auth.login(email.value.trim(), password.value);
if (r.requires_setup) {
router.push("/setup-2fa");
return;
}
if (r.requires_2fa) {
router.push("/verify-2fa");
return;
}
await auth.fetchMe();
router.push((route.query.redirect as string) || "/");
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-2xl font-semibold">Sign in</h1>
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
<input v-model="email" type="email" class="input-field" required autocomplete="username" />
</div>
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
</div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
</form>
</div>
</div>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api, type Rack } from "@/api";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const auth = useAuthStore();
const rack = ref<Rack | null>(null);
const side = ref("front");
const showAddDevice = ref(false);
const showAddNonnet = ref(false);
const addForm = ref({ device_id: 0, position_u: 1, side: "front" });
const nonnetForm = ref({ nonnet_device_name: "", position_u: 1, side: "front" });
const err = ref("");
async function load() {
rack.value = await api.rack(Number(route.params.id));
const devs = rack.value?.site_devices || [];
if (devs.length) addForm.value.device_id = devs[0].id;
}
onMounted(load);
const siteDevices = () => rack.value?.site_devices || [];
const slots = (r: Rack) => {
const h = r.height_u;
const map: Record<number, typeof r.devices> = {};
for (const d of r.devices || []) {
if (d.side === side.value) (map[d.position_u] ??= []).push(d);
}
return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] }));
};
async function addDevice() {
err.value = "";
try {
await api.addRackDevice(Number(route.params.id), {
device_id: addForm.value.device_id,
position_u: addForm.value.position_u,
side: addForm.value.side,
});
showAddDevice.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function addNonnet() {
err.value = "";
try {
await api.addRackDevice(Number(route.params.id), {
nonnet_device_name: nonnetForm.value.nonnet_device_name,
position_u: nonnetForm.value.position_u,
side: nonnetForm.value.side,
});
showAddNonnet.value = false;
nonnetForm.value.nonnet_device_name = "";
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function removeDevice(rackDeviceId: number) {
if (!confirm("Remove this device from the rack?")) return;
await api.removeRackDevice(Number(route.params.id), rackDeviceId);
await load();
}
</script>
<template>
<div v-if="rack">
<RouterLink to="/racks" class="text-sm text-accent hover:underline"> Racks</RouterLink>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ rack.name }}</h1>
<p class="text-slate-500">{{ rack.site }} · {{ rack.height_u }}U</p>
</div>
<a v-if="auth.can('export_rack_csv')" :href="`/api/v2/racks/${rack.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'front' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'front'">Front</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'back' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'back'">Back</button>
<button v-if="auth.can('add_device_to_rack')" class="btn-secondary text-sm" @click="showAddDevice = true; err = ''">Add device</button>
<button v-if="auth.can('add_nonnet_device_to_rack')" class="btn-secondary text-sm" @click="showAddNonnet = true; err = ''">Add non-networked</button>
</div>
<div class="card mt-6 max-w-md font-mono text-sm">
<div v-for="row in slots(rack)" :key="row.u" class="flex border-b border-slate-200 py-2 dark:border-slate-700">
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
<span class="flex flex-1 flex-col gap-1">
<span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
<RouterLink v-if="d.device_id" :to="`/devices/${d.device_id}`" class="text-accent hover:underline">{{ d.device_name }}</RouterLink>
<span v-else>{{ d.nonnet_device_name }}</span>
<button v-if="auth.can('remove_device_from_rack')" class="text-red-500 hover:underline" @click="removeDevice(d.id)">×</button>
</span>
<span v-if="!row.devices.length" class="text-slate-500"></span>
</span>
</div>
</div>
<div v-if="showAddDevice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddDevice = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="addDevice">
<h2 class="text-lg font-semibold">Add device to rack</h2>
<select v-model="addForm.device_id" class="input-field" required>
<option v-for="d in siteDevices()" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<input v-model.number="addForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
<select v-model="addForm.side" class="input-field">
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<p class="text-xs text-slate-500">For multi-U devices, add each U separately.</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Add</button>
<button type="button" class="btn-secondary" @click="showAddDevice = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showAddNonnet" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddNonnet = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="addNonnet">
<h2 class="text-lg font-semibold">Add non-networked device</h2>
<input v-model="nonnetForm.nonnet_device_name" class="input-field" placeholder="Device name" required />
<input v-model.number="nonnetForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
<select v-model="nonnetForm.side" class="input-field">
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Add</button>
<button type="button" class="btn-secondary" @click="showAddNonnet = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Rack } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const racks = ref<Rack[]>([]);
const showAdd = ref(false);
const showEdit = ref(false);
const form = ref({ name: "", site: "", height_u: 42 });
const editId = ref(0);
const err = ref("");
async function load() {
racks.value = await api.racks();
}
onMounted(load);
async function create() {
err.value = "";
try {
await api.createRack({ ...form.value, height_u: Number(form.value.height_u) });
showAdd.value = false;
form.value = { name: "", site: "", height_u: 42 };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(r: Rack) {
editId.value = r.id;
form.value = { name: r.name, site: r.site, height_u: r.height_u };
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateRack(editId.value, { ...form.value, height_u: Number(form.value.height_u) });
showEdit.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete this rack?")) return;
await api.deleteRack(id);
await load();
}
</script>
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Racks</h1>
<button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button>
</div>
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="r in racks" :key="r.id" class="card">
<RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent">
<div class="font-medium">{{ r.name }}</div>
<div class="text-sm text-slate-500">{{ r.site }} · {{ r.height_u }}U · {{ r.percent_full ?? 0 }}% full</div>
</RouterLink>
<div v-if="auth.can('add_rack') || auth.can('delete_rack')" class="mt-3 flex gap-2">
<button v-if="auth.can('add_rack')" class="text-sm text-accent hover:underline" @click="openEdit(r)">Edit</button>
<button v-if="auth.can('delete_rack')" class="text-sm text-red-500 hover:underline" @click="del(r.id)">Delete</button>
</div>
</div>
</div>
<div v-if="showAdd || showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false; showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="showEdit ? saveEdit() : create()">
<h2 class="text-lg font-semibold">{{ showEdit ? "Edit rack" : "Add rack" }}</h2>
<input v-model="form.name" class="input-field" placeholder="Name" required />
<input v-model="form.site" class="input-field" placeholder="Site" required />
<input v-model.number="form.height_u" type="number" min="1" class="input-field" placeholder="Height (U)" required />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">{{ showEdit ? "Save" : "Create" }}</button>
<button type="button" class="btn-secondary" @click="showAdd = false; showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const step = ref<"generate" | "verify" | "done">("generate");
const qrCode = ref("");
const secret = ref("");
const code = ref("");
const backupCodes = ref<string[]>([]);
const err = ref("");
const router = useRouter();
const auth = useAuthStore();
onMounted(async () => {
try {
const r = await api.setup2fa("generate");
qrCode.value = r.qr_code || "";
secret.value = r.secret || "";
step.value = "verify";
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to start setup";
}
});
async function verify() {
err.value = "";
try {
const r = await api.setup2fa("verify", code.value.trim());
backupCodes.value = r.backup_codes || [];
step.value = "done";
await auth.fetchMe();
} catch (e) {
err.value = e instanceof Error ? e.message : "Invalid code";
}
}
function finish() {
router.push("/");
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-xl font-semibold">Set up 2FA</h1>
<div v-if="step === 'verify'" class="mt-4 space-y-4">
<img v-if="qrCode" :src="`data:image/png;base64,${qrCode}`" alt="QR" class="mx-auto rounded-lg" />
<p class="break-all font-mono text-xs text-slate-500">{{ secret }}</p>
<input v-model="code" class="input-field text-center font-mono" placeholder="6-digit code" maxlength="6" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button class="btn-primary w-full" @click="verify">Verify & enable</button>
</div>
<div v-else-if="step === 'done'" class="mt-4 space-y-4">
<p class="text-sm text-slate-500">Save these backup codes securely:</p>
<ul class="rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in backupCodes" :key="c">{{ c }}</li>
</ul>
<button class="btn-primary w-full" @click="finish">Continue</button>
</div>
<p v-else-if="err" class="mt-4 text-red-500">{{ err }}</p>
<p v-else class="mt-4 text-slate-500">Loading</p>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
import IpHistoryModal from "@/components/IpHistoryModal.vue";
const route = useRoute();
const auth = useAuthStore();
const subnet = ref<Subnet | null>(null);
const historyIp = ref<string | null>(null);
onMounted(async () => {
subnet.value = await api.subnet(Number(route.params.id));
});
async function saveNotes(ipId: number, notes: string) {
await api.patchIpNotes(ipId, notes);
}
</script>
<template>
<div v-if="subnet">
<RouterLink to="/" class="text-sm text-accent hover:underline"> Home</RouterLink>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p>
</div>
<div class="flex gap-2">
<RouterLink :to="`/subnets/${subnet.id}/dhcp`" class="btn-secondary text-sm">DHCP</RouterLink>
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="p-2 font-medium">IP</th>
<th class="p-2 font-medium">Hostname</th>
<th class="p-2 font-medium">Notes</th>
<th class="p-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="ip in subnet.ip_addresses" :key="ip.id" class="border-b border-slate-100 dark:border-slate-800">
<td class="p-2 font-mono">{{ ip.ip }}</td>
<td class="p-2">
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline">{{ ip.device_name || ip.hostname }}</RouterLink>
<span v-else>{{ ip.hostname || "" }}</span>
</td>
<td class="p-2">
<input
v-if="auth.can('edit_subnet')"
:value="ip.notes || ''"
class="input-field py-1 text-xs"
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
/>
<span v-else>{{ ip.notes || "" }}</span>
</td>
<td class="p-2">
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
</td>
</tr>
</tbody>
</table>
</div>
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const subnets = ref<Subnet[]>([]);
const form = ref({ name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
const editForm = ref({ id: 0, name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
const showEdit = ref(false);
const err = ref("");
onMounted(async () => { subnets.value = await api.subnets(); });
async function reload() {
subnets.value = await api.subnets();
}
async function add() {
err.value = "";
try {
const body: Partial<Subnet> = {
name: form.value.name,
cidr: form.value.cidr,
site: form.value.site,
vlan_description: form.value.vlan_description || undefined,
vlan_notes: form.value.vlan_notes || undefined,
};
if (form.value.vlan_id) body.vlan_id = Number(form.value.vlan_id);
await api.createSubnet(body);
form.value = { name: "", cidr: "", site: "", vlan_id: "", vlan_description: "", vlan_notes: "" };
await reload();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(s: Subnet) {
editForm.value = {
id: s.id,
name: s.name,
cidr: s.cidr,
site: s.site || "",
vlan_id: s.vlan_id ?? "",
vlan_description: s.vlan_description || "",
vlan_notes: s.vlan_notes || "",
};
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
const body: Partial<Subnet> = {
name: editForm.value.name,
cidr: editForm.value.cidr,
site: editForm.value.site,
vlan_description: editForm.value.vlan_description || null,
vlan_notes: editForm.value.vlan_notes || null,
vlan_id: editForm.value.vlan_id ? Number(editForm.value.vlan_id) : null,
};
await api.updateSubnet(editForm.value.id, body);
showEdit.value = false;
await reload();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete subnet and all IPs?")) return;
await api.deleteSubnet(id);
await reload();
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnet management</h1>
<form v-if="auth.can('add_subnet')" class="card mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" @submit.prevent="add">
<input v-model="form.name" class="input-field" placeholder="Name" required />
<input v-model="form.cidr" class="input-field font-mono" placeholder="192.168.1.0/24" required />
<input v-model="form.site" class="input-field" placeholder="Site" />
<input v-model="form.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
<input v-model="form.vlan_description" class="input-field" placeholder="VLAN description" />
<input v-model="form.vlan_notes" class="input-field" placeholder="VLAN notes" />
<button class="btn-primary sm:col-span-2 lg:col-span-3 sm:max-w-xs">Add subnet</button>
<p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p>
</form>
<ul class="mt-8 space-y-2">
<li v-for="s in subnets" :key="s.id" class="card flex flex-wrap items-center justify-between gap-2">
<RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink>
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span v-if="s.vlan_id" class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs">VLAN {{ s.vlan_id }}</span>
<div class="flex gap-2">
<button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button>
<button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button>
</div>
</li>
</ul>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit subnet</h2>
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
<input v-model="editForm.cidr" class="input-field font-mono" placeholder="CIDR" required />
<input v-model="editForm.site" class="input-field" placeholder="Site" />
<input v-model="editForm.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
<input v-model="editForm.vlan_description" class="input-field" placeholder="VLAN description" />
<input v-model="editForm.vlan_notes" class="input-field" placeholder="VLAN notes" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type Tag } from "@/api";
const tags = ref<Tag[]>([]);
const form = ref({ name: "", color: "#06b6d4", description: "" });
const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" });
const showEdit = ref(false);
const err = ref("");
onMounted(async () => { tags.value = await api.tags(); });
async function create() {
await api.createTag(form.value);
tags.value = await api.tags();
form.value = { name: "", color: "#06b6d4", description: "" };
}
async function del(id: number) {
if (!confirm("Delete tag?")) return;
await api.deleteTag(id);
tags.value = await api.tags();
}
function openEdit(t: Tag) {
editForm.value = { id: t.id, name: t.name, color: t.color || "#06b6d4", description: t.description || "" };
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateTag(editForm.value.id, {
name: editForm.value.name,
color: editForm.value.color,
description: editForm.value.description,
});
showEdit.value = false;
tags.value = await api.tags();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Tags</h1>
<form class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
<input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required />
<input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" />
<input v-model="form.description" class="input-field max-w-xs" placeholder="Description" />
<button class="btn-primary">Add tag</button>
</form>
<ul class="mt-6 space-y-2">
<li v-for="t in tags" :key="t.id" class="card flex items-center justify-between">
<span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span>
<div class="flex gap-2">
<button class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
<button class="text-sm text-red-500" @click="del(t.id)">Delete</button>
</div>
</li>
</ul>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit tag</h2>
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
<input v-model="editForm.color" type="color" class="h-10 w-14 rounded border-0" />
<input v-model="editForm.description" class="input-field" placeholder="Description" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { api, type UserRow, type RoleRow } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tab = ref<"users" | "roles">("users");
const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
const err = ref("");
const showUserForm = ref(false);
const editUserId = ref<number | null>(null);
const userForm = ref({ name: "", email: "", password: "", role_id: 0 });
const showRoleForm = ref(false);
const editRoleId = ref<number | null>(null);
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
const showApiKey = ref("");
const permByCategory = computed(() => {
const m: Record<string, typeof permissions.value> = {};
for (const p of permissions.value) {
const cat = p.category || "Other";
(m[cat] ??= []).push(p);
}
return m;
});
async function load() {
[users.value, roles.value] = await Promise.all([api.users(), api.roles()]);
if (auth.can("manage_roles")) {
permissions.value = await api.permissions().catch(() => []);
}
}
onMounted(load);
function openAddUser() {
editUserId.value = null;
userForm.value = { name: "", email: "", password: "", role_id: roles.value[0]?.id ?? 0 };
showUserForm.value = true;
err.value = "";
}
function openEditUser(u: UserRow) {
editUserId.value = u.id;
userForm.value = { name: u.name, email: u.email, password: "", role_id: u.role_id ?? 0 };
showUserForm.value = true;
err.value = "";
}
async function saveUser() {
err.value = "";
try {
if (editUserId.value) {
const body: Record<string, unknown> = { name: userForm.value.name, email: userForm.value.email, role_id: userForm.value.role_id };
if (userForm.value.password) body.password = userForm.value.password;
await api.updateUser(editUserId.value, body);
} else {
await api.createUser(userForm.value);
}
showUserForm.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function delUser(id: number) {
if (!confirm("Delete this user?")) return;
await api.deleteUser(id);
await load();
}
async function regenKey(id: number) {
if (!confirm("Regenerate API key? The old key will stop working.")) return;
const r = await api.regenerateApiKey(id);
showApiKey.value = r.api_key;
}
function openAddRole() {
editRoleId.value = null;
roleForm.value = { name: "", description: "", require_2fa: false, permission_ids: [] };
showRoleForm.value = true;
err.value = "";
}
function openEditRole(r: RoleRow) {
editRoleId.value = r.id;
roleForm.value = {
name: r.name,
description: r.description || "",
require_2fa: !!r.require_2fa,
permission_ids: r.permissions?.map((p) => p.id) ?? [],
};
showRoleForm.value = true;
err.value = "";
}
function togglePerm(id: number) {
const idx = roleForm.value.permission_ids.indexOf(id);
if (idx >= 0) roleForm.value.permission_ids.splice(idx, 1);
else roleForm.value.permission_ids.push(id);
}
async function saveRole() {
err.value = "";
try {
if (editRoleId.value) {
await api.updateRole(editRoleId.value, roleForm.value);
} else {
await api.createRole(roleForm.value);
}
showRoleForm.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function delRole(id: number) {
if (!confirm("Delete this role?")) return;
await api.deleteRole(id);
await load();
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Users & roles</h1>
<div class="mt-4 flex gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
</div>
<section v-if="tab === 'users'" class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-accent">Users</h2>
<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>
<span class="text-sm text-slate-500">{{ u.role_name }}</span>
<div v-if="auth.can('manage_users')" class="flex gap-2">
<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>
</div>
</li>
</ul>
</section>
<section v-if="tab === 'roles'" class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-accent">Roles</h2>
<button v-if="auth.can('manage_roles')" class="btn-primary text-sm" @click="openAddRole">Add role</button>
</div>
<ul class="space-y-2">
<li v-for="r in roles" :key="r.id" class="card">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-medium">{{ r.name }} <span v-if="r.require_2fa" class="text-xs text-slate-500">(2FA required)</span></div>
<div class="text-sm text-slate-500">{{ r.description }}</div>
</div>
<div v-if="auth.can('manage_roles')" class="flex gap-2">
<button class="text-sm text-accent hover:underline" @click="openEditRole(r)">Edit</button>
<button class="text-sm text-red-500 hover:underline" @click="delRole(r.id)">Delete</button>
</div>
</div>
</li>
</ul>
</section>
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>
<input v-model="userForm.name" class="input-field" placeholder="Name" required />
<input v-model="userForm.email" type="email" class="input-field" placeholder="Email" required />
<input v-model="userForm.password" type="password" class="input-field" :placeholder="editUserId ? 'New password (optional)' : 'Password'" :required="!editUserId" />
<select v-model="userForm.role_id" class="input-field">
<option v-for="r in roles" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showUserForm = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showRoleForm" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]" @click.self="showRoleForm = false">
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveRole">
<h2 class="text-lg font-semibold">{{ editRoleId ? "Edit role" : "Add role" }}</h2>
<input v-model="roleForm.name" class="input-field" placeholder="Name" required />
<input v-model="roleForm.description" class="input-field" placeholder="Description" />
<label class="flex items-center gap-2 text-sm">
<input v-model="roleForm.require_2fa" type="checkbox" />
Require 2FA
</label>
<div v-if="permissions.length" class="max-h-48 overflow-y-auto rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div v-for="(perms, cat) in permByCategory" :key="cat" class="mb-3">
<div class="text-xs font-semibold uppercase text-slate-500">{{ cat }}</div>
<label v-for="p in perms" :key="p.id" class="mt-1 flex items-center gap-2 text-sm">
<input type="checkbox" :checked="roleForm.permission_ids.includes(p.id)" @change="togglePerm(p.id)" />
{{ p.name }}
</label>
</div>
</div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showRoleForm = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
<div class="card w-full max-w-md space-y-3">
<h2 class="text-lg font-semibold">New API key</h2>
<p class="text-sm text-slate-500">Copy this key now it won't be shown again.</p>
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
<button class="btn-primary" @click="showApiKey = ''">Done</button>
</div>
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const code = ref("");
const useBackup = ref(false);
const err = ref("");
const busy = ref(false);
const router = useRouter();
const auth = useAuthStore();
async function submit() {
err.value = "";
busy.value = true;
try {
await api.verify2fa(code.value.trim(), useBackup.value);
await auth.fetchMe();
router.push("/");
} catch (e) {
err.value = e instanceof Error ? e.message : "Verification failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-xl font-semibold">Two-factor authentication</h1>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<input v-model="code" class="input-field text-center font-mono text-lg tracking-widest" :placeholder="useBackup ? 'Backup code' : '000000'" />
<label class="flex items-center gap-2 text-sm">
<input v-model="useBackup" type="checkbox" /> Use backup code
</label>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button class="btn-primary w-full" :disabled="busy">Verify</button>
</form>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
darkMode: "media",
theme: {
extend: {
colors: {
surface: {
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
raised: "rgb(var(--surface-raised) / <alpha-value>)",
overlay: "rgb(var(--surface-overlay) / <alpha-value>)",
},
accent: {
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
muted: "rgb(var(--accent-muted) / <alpha-value>)",
},
},
fontFamily: {
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
},
},
},
plugins: [],
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] },
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+56
View File
@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import vue from "@vitejs/plugin-vue";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staticRoot = path.resolve(__dirname, "../static");
function servePwaFromStatic(): Plugin {
return {
name: "serve-pwa-from-static",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
next();
return;
}
const name = url.slice(1);
const filePath = path.join(staticRoot, name);
if (!fs.existsSync(filePath)) {
next();
return;
}
const body = fs.readFileSync(filePath);
const type = name.endsWith(".webmanifest")
? "application/manifest+json"
: "application/javascript";
res.setHeader("Content-Type", type);
res.end(body);
});
},
};
}
export default defineConfig({
plugins: [vue(), servePwaFromStatic()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
build: {
outDir: "../static/dist",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5000",
"/ws": {
target: "ws://127.0.0.1:5000",
ws: true,
},
},
},
});