feat: ✨ convert to api and rewrite ui
This commit is contained in:
@@ -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>
|
||||
Generated
+2671
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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 = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"><{{ u.email }}></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>
|
||||
@@ -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>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user