feat: ✨ convert to api and rewrite ui
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user