feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,625 @@
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef, useState } from 'react'
import { RugLogo } from '@/components/brand/RugLogo'
export const Route = createFileRoute('/_authenticated/about')({
component: AboutPage,
})
// ── Minimal QR Code Generator (no dependencies) ─────────────────────────────
// Implements a basic QR encoder for alphanumeric/byte mode, version 1-4
// Sufficient for encoding a Bitcoin address (~62 chars)
const BTC_ADDRESS = 'bc1qfw6pmyc96vrlkpc0rgun0s7xy4sqhx7a2xurkf'
// Generate QR matrix for a given string using a minimal implementation
function generateQRMatrix(data: string): boolean[][] {
// Use the canvas-based approach with a simple encoding
// For a self-contained solution, we'll generate a basic QR-like pattern
// This is a real QR encoder for small payloads
const codewords = encodeData(data)
const version = getMinVersion(data.length)
const size = 17 + version * 4
const matrix: (boolean | null)[][] = Array.from({ length: size }, () =>
Array(size).fill(null)
)
// Place finder patterns
placeFinder(matrix, 0, 0)
placeFinder(matrix, 0, size - 7)
placeFinder(matrix, size - 7, 0)
// Place timing patterns
for (let i = 8; i < size - 8; i++) {
matrix[6][i] = i % 2 === 0
matrix[i][6] = i % 2 === 0
}
// Place alignment pattern for version >= 2
if (version >= 2) {
const pos = alignmentPositions[version]
if (pos) {
for (const r of pos) {
for (const c of pos) {
if (matrix[r]?.[c] === null) {
placeAlignment(matrix, r, c)
}
}
}
}
}
// Dark module
matrix[size - 8][8] = true
// Reserve format info
for (let i = 0; i < 8; i++) {
if (matrix[8][i] === null) matrix[8][i] = false
if (matrix[i][8] === null) matrix[i][8] = false
if (matrix[8][size - 1 - i] === null) matrix[8][size - 1 - i] = false
if (matrix[size - 1 - i][8] === null) matrix[size - 1 - i][8] = false
}
if (matrix[8][8] === null) matrix[8][8] = false
// Place data bits
placeData(matrix, codewords, size)
// Apply mask (mask 0: (row + col) % 2 === 0)
const result: boolean[][] = matrix.map((row, r) =>
row.map((cell, c) => {
if (cell === null) return false
const isData = !isReserved(matrix, r, c, size, version)
if (isData) {
const mask = (r + c) % 2 === 0
return mask ? !cell : (cell as boolean)
}
return cell as boolean
})
)
// Place format info for mask 0, error correction L
placeFormatInfo(result, size)
return result
}
function getMinVersion(len: number): number {
// Byte mode capacities for EC level L
if (len <= 17) return 1
if (len <= 32) return 2
if (len <= 53) return 3
return 4
}
const alignmentPositions: Record<number, number[]> = {
2: [6, 18],
3: [6, 22],
4: [6, 26],
}
function placeFinder(matrix: (boolean | null)[][], row: number, col: number) {
for (let r = -1; r <= 7; r++) {
for (let c = -1; c <= 7; c++) {
const mr = row + r
const mc = col + c
if (mr < 0 || mc < 0 || mr >= matrix.length || mc >= matrix.length)
continue
if (r === -1 || r === 7 || c === -1 || c === 7) {
matrix[mr][mc] = false // separator
} else if (
r === 0 ||
r === 6 ||
c === 0 ||
c === 6 ||
(r >= 2 && r <= 4 && c >= 2 && c <= 4)
) {
matrix[mr][mc] = true
} else {
matrix[mr][mc] = false
}
}
}
}
function placeAlignment(
matrix: (boolean | null)[][],
row: number,
col: number
) {
for (let r = -2; r <= 2; r++) {
for (let c = -2; c <= 2; c++) {
const mr = row + r
const mc = col + c
if (mr < 0 || mc < 0 || mr >= matrix.length || mc >= matrix.length)
continue
if (matrix[mr][mc] !== null) continue
if (
Math.abs(r) === 2 ||
Math.abs(c) === 2 ||
(r === 0 && c === 0)
) {
matrix[mr][mc] = true
} else {
matrix[mr][mc] = false
}
}
}
}
function encodeData(data: string): number[] {
const version = getMinVersion(data.length)
// Total codewords for version + EC level L
const totalCodewords = [0, 26, 44, 70, 100][version]!
const ecCodewords = [0, 7, 10, 15, 20][version]!
const dataCodewords = totalCodewords - ecCodewords
// Byte mode: mode indicator (0100) + char count + data
const bits: number[] = []
// Mode: byte (0100)
pushBits(bits, 0b0100, 4)
// Character count (8 bits for version 1-9)
pushBits(bits, data.length, 8)
// Data
for (let i = 0; i < data.length; i++) {
pushBits(bits, data.charCodeAt(i), 8)
}
// Terminator
const maxBits = dataCodewords * 8
const termLen = Math.min(4, maxBits - bits.length)
pushBits(bits, 0, termLen)
// Pad to byte boundary
while (bits.length % 8 !== 0) bits.push(0)
// Pad codewords
const padWords = [0xec, 0x11]
let padIdx = 0
while (bits.length < maxBits) {
pushBits(bits, padWords[padIdx % 2], 8)
padIdx++
}
// Convert to bytes
const dataBytes: number[] = []
for (let i = 0; i < bits.length; i += 8) {
let byte = 0
for (let j = 0; j < 8; j++) {
byte = (byte << 1) | (bits[i + j] || 0)
}
dataBytes.push(byte)
}
// Generate EC codewords using Reed-Solomon
const ecBytes = generateEC(dataBytes, ecCodewords)
return [...dataBytes, ...ecBytes]
}
function pushBits(arr: number[], value: number, count: number) {
for (let i = count - 1; i >= 0; i--) {
arr.push((value >> i) & 1)
}
}
// GF(256) arithmetic for Reed-Solomon
const GF_EXP = new Uint8Array(512)
const GF_LOG = new Uint8Array(256)
;(function initGF() {
let x = 1
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x
GF_LOG[x] = i
x = x << 1
if (x >= 256) x ^= 0x11d
}
for (let i = 255; i < 512; i++) {
GF_EXP[i] = GF_EXP[i - 255]
}
})()
function gfMul(a: number, b: number): number {
if (a === 0 || b === 0) return 0
return GF_EXP[GF_LOG[a] + GF_LOG[b]]
}
function generateEC(data: number[], ecLen: number): number[] {
// Build generator polynomial
let gen = [1]
for (let i = 0; i < ecLen; i++) {
const newGen = new Array(gen.length + 1).fill(0)
for (let j = 0; j < gen.length; j++) {
newGen[j] ^= gen[j]
newGen[j + 1] ^= gfMul(gen[j], GF_EXP[i])
}
gen = newGen
}
const result = new Array(ecLen).fill(0)
const msg = [...data, ...result]
for (let i = 0; i < data.length; i++) {
const coef = msg[i]
if (coef !== 0) {
for (let j = 0; j < gen.length; j++) {
msg[i + j] ^= gfMul(gen[j], coef)
}
}
}
return msg.slice(data.length)
}
function isReserved(
_matrix: (boolean | null)[][],
r: number,
c: number,
size: number,
version: number
): boolean {
// Finder + separator areas
if (r <= 8 && c <= 8) return true
if (r <= 8 && c >= size - 8) return true
if (r >= size - 8 && c <= 8) return true
// Timing
if (r === 6 || c === 6) return true
// Dark module
if (r === size - 8 && c === 8) return true
// Alignment patterns (version >= 2)
if (version >= 2) {
const pos = alignmentPositions[version]
if (pos) {
for (const ar of pos) {
for (const ac of pos) {
if (ar <= 8 && ac <= 8) continue
if (ar <= 8 && ac >= size - 8) continue
if (ar >= size - 8 && ac <= 8) continue
if (Math.abs(r - ar) <= 2 && Math.abs(c - ac) <= 2) return true
}
}
}
}
return false
}
function placeData(
matrix: (boolean | null)[][],
codewords: number[],
size: number
) {
let bitIdx = 0
const totalBits = codewords.length * 8
let col = size - 1
let upward = true
while (col >= 0) {
if (col === 6) col-- // skip timing column
const rows = upward
? Array.from({ length: size }, (_, i) => size - 1 - i)
: Array.from({ length: size }, (_, i) => i)
for (const row of rows) {
for (const dc of [0, -1]) {
const c = col + dc
if (c < 0 || c >= size) continue
if (matrix[row][c] !== null) continue
if (bitIdx < totalBits) {
const byteIdx = Math.floor(bitIdx / 8)
const bitPos = 7 - (bitIdx % 8)
matrix[row][c] = ((codewords[byteIdx] >> bitPos) & 1) === 1
bitIdx++
} else {
matrix[row][c] = false
}
}
}
col -= 2
upward = !upward
}
}
function placeFormatInfo(matrix: boolean[][], size: number) {
// Format info for EC level L (01) and mask 0 (000) = 01000
// After BCH: 0x77c0... Let's use the precomputed value
// EC L = 01, mask 0 = 000 -> data = 01000
// Format string after BCH and XOR with 101010000010010:
const formatBits = 0x77c0 // L, mask 0
// Actually, let's compute it properly
// data = 01 000 = 0b01000 = 8
// Generator: 10100110111 (0x537)
// BCH encode then XOR with mask pattern
const formatInfo = getFormatInfo(0, 0) // ecl=L(01->index 0 in our simplified), mask=0
// Place format info
const bits: boolean[] = []
for (let i = 14; i >= 0; i--) {
bits.push(((formatInfo >> i) & 1) === 1)
}
// Around top-left finder
const positions1 = [
[8, 0],
[8, 1],
[8, 2],
[8, 3],
[8, 4],
[8, 5],
[8, 7],
[8, 8],
[7, 8],
[5, 8],
[4, 8],
[3, 8],
[2, 8],
[1, 8],
[0, 8],
]
// Around other finders
const positions2 = [
[size - 1, 8],
[size - 2, 8],
[size - 3, 8],
[size - 4, 8],
[size - 5, 8],
[size - 6, 8],
[size - 7, 8],
[8, size - 8],
[8, size - 7],
[8, size - 6],
[8, size - 5],
[8, size - 4],
[8, size - 3],
[8, size - 2],
[8, size - 1],
]
for (let i = 0; i < 15; i++) {
const [r1, c1] = positions1[i]
matrix[r1][c1] = bits[i]
const [r2, c2] = positions2[i]
matrix[r2][c2] = bits[i]
}
}
function getFormatInfo(_ecl: number, mask: number): number {
// Pre-computed format info strings for EC level L with each mask
// EC Level L = 01
const formatInfoL = [
0x77c4, // mask 0
0x72f3, // mask 1
0x7daa, // mask 2
0x789d, // mask 3
0x662f, // mask 4
0x6318, // mask 5
0x6c41, // mask 6
0x6976, // mask 7
]
return formatInfoL[mask] ?? 0x77c4
}
// ── QR Canvas Component ──────────────────────────────────────────────────────
function QRCode({
data,
size = 200,
fgColor = '#e2e8f0',
bgColor = 'transparent',
}: {
data: string
size?: number
fgColor?: string
bgColor?: string
}) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
try {
const matrix = generateQRMatrix(data)
const moduleCount = matrix.length
const moduleSize = size / moduleCount
canvas.width = size
canvas.height = size
// Background
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, size, size)
// Modules
ctx.fillStyle = fgColor
for (let r = 0; r < moduleCount; r++) {
for (let c = 0; c < moduleCount; c++) {
if (matrix[r][c]) {
ctx.fillRect(
Math.floor(c * moduleSize),
Math.floor(r * moduleSize),
Math.ceil(moduleSize),
Math.ceil(moduleSize)
)
}
}
}
} catch {
// Fallback: draw a placeholder
canvas.width = size
canvas.height = size
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, size, size)
ctx.fillStyle = fgColor
ctx.font = '14px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('QR Code', size / 2, size / 2)
}
}, [data, size, fgColor, bgColor])
return <canvas ref={canvasRef} width={size} height={size} className="rounded" />
}
// ── About Page ───────────────────────────────────────────────────────────────
function AboutPage() {
const [copied, setCopied] = useState(false)
const [showQR, setShowQR] = useState(false)
const copyAddress = async () => {
try {
await navigator.clipboard.writeText(BTC_ADDRESS)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback for older browsers
const el = document.createElement('textarea')
el.value = BTC_ADDRESS
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="p-6 max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="text-center space-y-3">
<RugLogo size={64} />
<h1 className="text-2xl font-bold text-text-primary">TOD - The Other Dude</h1>
<p className="text-text-secondary">
MSP fleet management platform for RouterOS devices
</p>
<span className="inline-block px-3 py-1 text-xs font-mono font-medium text-accent bg-accent-muted rounded-full">
v6.0
</span>
</div>
{/* Features summary */}
<div className="rounded-lg border border-border bg-surface p-5 space-y-3">
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
Platform
</h2>
<div className="grid grid-cols-2 gap-3 text-sm text-text-secondary">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
Multi-tenant with RLS
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
RouterOS binary API
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
Real-time monitoring
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
Safe config push
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
Certificate authority
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
WireGuard VPN
</div>
</div>
</div>
{/* Support Development */}
<div className="rounded-lg border border-border bg-surface p-5 space-y-4">
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
Support Development
</h2>
<p className="text-sm text-text-muted">
The Other Dude is free and open-source. If you find it valuable,
voluntary Bitcoin contributions are appreciated but never expected.
</p>
<button
onClick={() => setShowQR(!showQR)}
className="text-xs text-accent hover:underline"
>
{showQR ? 'Hide donation address' : 'Show donation address'}
</button>
{showQR && (
<div className="flex flex-col items-center gap-4 py-4">
<div className="p-3 rounded-lg bg-background border border-border">
<QRCode
data={`bitcoin:${BTC_ADDRESS}`}
size={160}
fgColor="hsl(215 20.2% 75.1%)"
bgColor="transparent"
/>
</div>
<div className="w-full space-y-2">
<p className="text-xs text-text-muted text-center">Bitcoin Address</p>
<button
onClick={copyAddress}
className="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-background border border-border text-xs font-mono text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors cursor-pointer"
title="Click to copy"
>
<span className="truncate">{BTC_ADDRESS}</span>
<span className="flex-shrink-0 text-text-muted">
{copied ? (
<svg
className="w-4 h-4 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
</span>
</button>
</div>
</div>
)}
</div>
{/* Footer */}
<p className="text-center text-xs text-text-muted">
Not affiliated with or endorsed by MikroTik (SIA Mikrotikls)
</p>
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AlertRulesPage } from '@/components/alerts/AlertRulesPage'
export const Route = createFileRoute('/_authenticated/alert-rules')({
component: AlertRulesPage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { AlertsPage } from '@/components/alerts/AlertsPage'
export const Route = createFileRoute('/_authenticated/alerts')({
component: AlertsPage,
})

View File

@@ -0,0 +1,64 @@
/**
* Audit Trail page route -- /_authenticated/audit
*
* Displays a centralized, filterable audit log for MSP accountability.
* Uses the global org selector in the header for tenant context.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, ClipboardList } from 'lucide-react'
import { useAuth, isSuperAdmin, isOperator } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { AuditLogTable } from '@/components/audit/AuditLogTable'
export const Route = createFileRoute('/_authenticated/audit')({
component: AuditPage,
})
function AuditPage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least operator role
if (!isOperator(user)) {
return (
<div className="max-w-6xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-text-muted" />
Audit Trail
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need at least operator permissions to view the audit trail.
</p>
</div>
</div>
)
}
return (
<div className="max-w-6xl space-y-4">
{/* Title */}
<h1 className="text-lg font-semibold flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-text-muted" />
Audit Trail
</h1>
{/* Audit log table or empty state */}
{tenantId ? (
<AuditLogTable tenantId={tenantId} />
) : (
<div className="rounded-lg border border-border bg-surface p-12 text-center">
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view audit logs.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,70 @@
/**
* Batch Configuration page route -- /_authenticated/batch-config
*
* Allows operators to apply the same configuration change to multiple
* devices at once using a 3-step wizard. Requires at least operator role.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Wrench, ChevronRight, Building2 } from 'lucide-react'
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { BatchConfigPanel } from '@/components/config/BatchConfigPanel'
export const Route = createFileRoute('/_authenticated/batch-config')({
component: BatchConfigPage,
})
function BatchConfigPage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least operator role
if (!canWrite(user)) {
return (
<div className="max-w-3xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Wrench className="h-5 w-5 text-text-muted" />
Batch Configuration
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need at least operator permissions to use batch configuration.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<span>Home</span>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Batch Config</span>
</div>
{/* Title */}
<h1 className="text-lg font-semibold flex items-center gap-2">
<Wrench className="h-5 w-5 text-text-muted" />
Batch Configuration
</h1>
{/* Panel */}
{tenantId ? (
<BatchConfigPanel tenantId={tenantId} />
) : (
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-2">
<Building2 className="h-6 w-6 mx-auto text-text-muted" />
<p className="text-sm text-text-muted">
Select an organization from the header to get started.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,74 @@
/**
* Bulk Commands page route -- /_authenticated/bulk-commands
*
* Allows operators to execute a RouterOS CLI command across multiple
* devices at once using a 3-step wizard. Requires at least operator role.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, Terminal, ChevronRight } from 'lucide-react'
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { BulkCommandWizard } from '@/components/operations/BulkCommandWizard'
export const Route = createFileRoute('/_authenticated/bulk-commands')({
component: BulkCommandsPage,
})
function BulkCommandsPage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least operator role
if (!canWrite(user)) {
return (
<div className="max-w-3xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Terminal className="h-5 w-5 text-text-muted" />
Bulk Commands
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need at least operator permissions to use bulk commands.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<span>Home</span>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Bulk Commands</span>
</div>
{/* Title + tenant selector */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Terminal className="h-5 w-5 text-text-muted" />
Bulk Commands
</h1>
</div>
{/* Panel */}
{tenantId ? (
<BulkCommandWizard tenantId={tenantId} />
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Building2 className="h-10 w-10 text-text-muted mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view bulk commands.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { CertificatesPage } from '@/components/certificates/CertificatesPage'
export const Route = createFileRoute('/_authenticated/certificates')({
component: CertificatesPage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { ConfigEditorPage } from '@/components/config-editor/ConfigEditorPage'
export const Route = createFileRoute('/_authenticated/config-editor')({
component: ConfigEditorPage,
})

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { FirmwarePage } from '@/components/firmware/FirmwarePage'
export const Route = createFileRoute('/_authenticated/firmware')({
component: FirmwarePage,
})

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { FleetDashboard } from '@/components/fleet/FleetDashboard'
import { CardGridSkeleton } from '@/components/ui/page-skeleton'
import { useAuth, isSuperAdmin } from '@/lib/auth'
import { tenantsApi } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/')({
component: FleetDashboardPage,
})
function FleetDashboardPage() {
const { user } = useAuth()
const navigate = useNavigate()
// Only check for super_admin -- regular users always have a tenant
const shouldCheck = isSuperAdmin(user)
const { data: tenants, isLoading: tenantsLoading } = useQuery({
queryKey: ['tenants'],
queryFn: tenantsApi.list,
enabled: shouldCheck,
})
// Filter out the System (Internal) tenant — only real customer tenants count
const realTenants = tenants?.filter(
(t) => t.id !== '00000000-0000-0000-0000-000000000000',
)
useEffect(() => {
if (shouldCheck && !tenantsLoading && tenants && realTenants && realTenants.length === 0) {
void navigate({ to: '/setup' })
}
}, [shouldCheck, tenantsLoading, tenants, realTenants, navigate])
// Show skeleton while checking (super_admin only)
if (shouldCheck && tenantsLoading) {
return <CardGridSkeleton />
}
return <FleetDashboard />
}

View File

@@ -0,0 +1,75 @@
/**
* Maintenance Windows page route -- /_authenticated/maintenance
*
* Allows operators to schedule maintenance windows with alert suppression.
* Shows active, upcoming, and past windows in a timeline layout.
* Requires at least operator role.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, Calendar, ChevronRight } from 'lucide-react'
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { MaintenanceList } from '@/components/maintenance/MaintenanceList'
export const Route = createFileRoute('/_authenticated/maintenance')({
component: MaintenancePage,
})
function MaintenancePage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least operator role
if (!canWrite(user)) {
return (
<div className="max-w-3xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-text-muted" />
Maintenance Windows
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need at least operator permissions to manage maintenance windows.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<span>Home</span>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Maintenance</span>
</div>
{/* Title + tenant selector */}
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-text-muted" />
Maintenance Windows
</h1>
</div>
{/* Main content */}
{tenantId ? (
<MaintenanceList tenantId={tenantId} />
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Building2 className="h-10 w-10 text-text-muted mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view maintenance windows.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { MapPage } from '@/components/map/MapPage'
export const Route = createFileRoute('/_authenticated/map')({
component: MapPage,
})

View File

@@ -0,0 +1,73 @@
/**
* Reports page route -- /_authenticated/reports
*
* Allows operators to generate and download device inventory,
* metrics summary, alert history, and change log reports.
* Uses the global org selector in the header for tenant context.
* Requires at least operator role.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, FileText, ChevronRight } from 'lucide-react'
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { ReportsPage } from '@/components/reports/ReportsPage'
export const Route = createFileRoute('/_authenticated/reports')({
component: ReportsPageRoute,
})
function ReportsPageRoute() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least operator role
if (!canWrite(user)) {
return (
<div className="max-w-3xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5 text-text-muted" />
Reports
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need at least operator permissions to generate reports.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<span>Home</span>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Reports</span>
</div>
{/* Title */}
<h1 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5 text-text-muted" />
Reports
</h1>
{/* Reports panel */}
{tenantId ? (
<ReportsPage tenantId={tenantId} />
) : (
<div className="rounded-lg border border-border bg-surface p-12 text-center">
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view reports.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { createFileRoute } from '@tanstack/react-router'
import { ShieldAlert, Building2 } from 'lucide-react'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { ApiKeysPage } from '@/components/settings/ApiKeysPage'
export const Route = createFileRoute('/_authenticated/settings/api-keys')({
component: ApiKeysRoute,
})
function ApiKeysRoute() {
const { user } = useAuth()
const { selectedTenantId } = useUIStore()
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: only tenant_admin+ can manage API keys
if (!isTenantAdmin(user)) {
return (
<div className="space-y-6 max-w-2xl">
<div className="rounded-lg border border-border bg-surface px-6 py-12 text-center">
<ShieldAlert className="h-10 w-10 text-text-muted mx-auto mb-3" />
<h2 className="text-sm font-medium mb-1">Access Denied</h2>
<p className="text-sm text-text-muted">
You need tenant admin or higher permissions to manage API keys.
</p>
</div>
</div>
)
}
return (
<div className="space-y-4 max-w-4xl">
{!tenantId ? (
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-2">
<Building2 className="h-6 w-6 mx-auto text-text-muted" />
<p className="text-sm text-text-muted">
Select an organization from the header to manage API keys.
</p>
</div>
) : (
<ApiKeysPage tenantId={tenantId} />
)}
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { SettingsPage } from '@/components/settings/SettingsPage'
export const Route = createFileRoute('/_authenticated/settings')({
component: SettingsPage,
})

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { SetupWizard } from '@/components/setup/SetupWizard'
export const Route = createFileRoute('/_authenticated/setup')({
component: SetupPage,
})
function SetupPage() {
return <SetupWizard />
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { TemplatesPage } from '@/components/templates/TemplatesPage'
export const Route = createFileRoute('/_authenticated/templates')({
component: TemplatesPage,
})

View File

@@ -0,0 +1,883 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
ChevronRight,
Eye,
EyeOff,
Pencil,
Trash2,
Circle,
Tag,
FolderOpen,
BellOff,
BellRing,
CheckCircle,
ShieldCheck,
ShieldAlert,
ShieldOff,
Shield,
} from 'lucide-react'
import { devicesApi, deviceGroupsApi, deviceTagsApi, tenantsApi, type DeviceResponse, type DeviceUpdate } from '@/lib/api'
import { alertsApi } from '@/lib/alertsApi'
import { useAuth, canWrite, canDelete } from '@/lib/auth'
import { toast } from '@/components/ui/toast'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatUptime, formatDateTime, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { DetailPageSkeleton } from '@/components/ui/page-skeleton'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { InterfaceGauges } from '@/components/network/InterfaceGauges'
// Phase 27: Simple Configuration Interface
import { useSimpleConfigMode } from '@/hooks/useSimpleConfig'
import { SimpleModeToggle } from '@/components/simple-config/SimpleModeToggle'
import { SimpleConfigView } from '@/components/simple-config/SimpleConfigView'
export const Route = createFileRoute(
'/_authenticated/tenants/$tenantId/devices/$deviceId',
)({
component: DeviceDetailPage,
})
// ---------------------------------------------------------------------------
// Edit Device Dialog
// ---------------------------------------------------------------------------
function EditDeviceDialog({
device,
tenantId,
open,
onOpenChange,
}: {
device: DeviceResponse
tenantId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const [form, setForm] = useState<DeviceUpdate>({
hostname: device.hostname,
ip_address: device.ip_address,
api_port: device.api_port,
api_ssl_port: device.api_ssl_port,
username: '',
password: '',
latitude: device.latitude ?? undefined,
longitude: device.longitude ?? undefined,
})
const updateMutation = useMutation({
mutationFn: (data: DeviceUpdate) => devicesApi.update(tenantId, device.id, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, device.id] })
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
toast({ title: 'Device updated' })
onOpenChange(false)
},
onError: () => toast({ title: 'Failed to update device', variant: 'destructive' }),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Only send fields that are non-empty strings / defined numbers
const payload: DeviceUpdate = {
hostname: form.hostname || undefined,
ip_address: form.ip_address || undefined,
api_port: form.api_port,
api_ssl_port: form.api_ssl_port,
latitude: form.latitude,
longitude: form.longitude,
}
// Only include credentials if the user typed something
if (form.username) payload.username = form.username
if (form.password) payload.password = form.password
updateMutation.mutate(payload)
}
const field = (
id: string,
label: string,
value: string | number | undefined,
onChange: (v: string) => void,
opts?: { type?: string; placeholder?: string },
) => (
<div className="space-y-1">
<Label htmlFor={id} className="text-xs text-text-secondary">
{label}
</Label>
<Input
id={id}
type={opts?.type ?? 'text'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={opts?.placeholder}
className="h-8 text-sm"
/>
</div>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="dialog-edit-device">
<DialogHeader>
<DialogTitle>Edit Device</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="grid grid-cols-2 gap-3">
{field('hostname', 'Hostname', form.hostname, (v) => setForm((f) => ({ ...f, hostname: v })))}
{field('ip_address', 'IP Address', form.ip_address, (v) => setForm((f) => ({ ...f, ip_address: v })))}
{field('api_port', 'API Port', form.api_port, (v) => setForm((f) => ({ ...f, api_port: parseInt(v) || undefined })), { type: 'number' })}
{field('api_ssl_port', 'API TLS Port', form.api_ssl_port, (v) => setForm((f) => ({ ...f, api_ssl_port: parseInt(v) || undefined })), { type: 'number' })}
</div>
<div className="border-t border-border pt-3 space-y-1">
<p className="text-xs text-text-muted mb-2">Leave blank to keep existing credentials</p>
<div className="grid grid-cols-2 gap-3">
{field('username', 'Username', form.username, (v) => setForm((f) => ({ ...f, username: v })), { placeholder: 'unchanged' })}
{field('password', 'Password', form.password, (v) => setForm((f) => ({ ...f, password: v })), { type: 'password', placeholder: 'unchanged' })}
</div>
</div>
<div className="border-t border-border pt-3">
<p className="text-xs text-text-muted mb-2">GPS coordinates (optional)</p>
<div className="grid grid-cols-2 gap-3">
{field('latitude', 'Latitude', form.latitude, (v) => setForm((f) => ({ ...f, latitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })}
{field('longitude', 'Longitude', form.longitude, (v) => setForm((f) => ({ ...f, longitude: v ? parseFloat(v) : undefined })), { type: 'number', placeholder: '0.000000' })}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; className: string }> = {
online: { label: 'Online', className: 'text-success border-success/50 bg-success/10' },
offline: { label: 'Offline', className: 'text-error border-error/50 bg-error/10' },
unknown: { label: 'Unknown', className: 'text-text-muted border-border bg-elevated/50' },
}
const c = config[status] ?? config.unknown
return (
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
<Circle className="h-2 w-2 fill-current" />
{c.label}
</span>
)
}
function TlsSecurityBadge({ tlsMode }: { tlsMode: string }) {
const config: Record<string, { label: string; icon: React.ElementType; className: string }> = {
portal_ca: {
label: 'CA Verified',
icon: ShieldCheck,
className: 'text-success border-success/50 bg-success/10',
},
auto: {
label: 'Self-Signed TLS',
icon: Shield,
className: 'text-warning border-warning/50 bg-warning/10',
},
insecure: {
label: 'Insecure TLS',
icon: ShieldAlert,
className: 'text-orange-400 border-orange-400/50 bg-orange-400/10',
},
plain: {
label: 'Plain-Text (Insecure)',
icon: ShieldOff,
className: 'text-error border-error/50 bg-error/10',
},
}
const c = config[tlsMode] ?? config.auto
const Icon = c.icon
return (
<span className={cn('inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border', c.className)}>
<Icon className="h-3 w-3" />
{c.label}
</span>
)
}
function TlsModeSelector({
tenantId,
deviceId,
currentMode,
}: {
tenantId: string
deviceId: string
currentMode: string
}) {
const queryClient = useQueryClient()
const [confirmPlain, setConfirmPlain] = useState(false)
const [pendingMode, setPendingMode] = useState<string | null>(null)
const updateMutation = useMutation({
mutationFn: (mode: string) => devicesApi.update(tenantId, deviceId, { tls_mode: mode }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
toast({ title: 'TLS mode updated' })
setConfirmPlain(false)
setPendingMode(null)
},
onError: () => toast({ title: 'Failed to update TLS mode', variant: 'destructive' }),
})
const handleChange = (value: string) => {
if (value === 'plain') {
setPendingMode(value)
setConfirmPlain(true)
} else {
updateMutation.mutate(value)
}
}
return (
<>
<Select value={currentMode} onValueChange={handleChange}>
<SelectTrigger className="h-7 text-xs w-36" data-testid="select-tls-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (recommended)</SelectItem>
<SelectItem value="portal_ca">CA Verified</SelectItem>
<SelectItem value="insecure">Insecure TLS</SelectItem>
<SelectItem value="plain">Plain-Text</SelectItem>
</SelectContent>
</Select>
<Dialog open={confirmPlain} onOpenChange={setConfirmPlain}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-error">
<ShieldOff className="h-5 w-5" />
Enable Plain-Text Connection?
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-text-secondary">
<p>
Plain-text mode sends credentials and all data unencrypted over the network.
This is a serious security risk and should only be used for devices that
do not support TLS at all.
</p>
<div className="rounded border border-error/30 bg-error/5 px-3 py-2 text-xs text-error">
Credentials will be transmitted in clear text. Anyone on the network
can intercept them.
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmPlain(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={updateMutation.isPending}
onClick={() => pendingMode && updateMutation.mutate(pendingMode)}
>
{updateMutation.isPending ? 'Saving...' : 'Enable Plain-Text'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-4 py-2 border-b border-border/50 last:border-0">
<span className="text-xs text-text-muted w-32 flex-shrink-0 pt-0.5">{label}</span>
<span className="text-sm text-text-primary flex-1">{value ?? '—'}</span>
</div>
)
}
function DeviceDetailPage() {
const { tenantId, deviceId } = Route.useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user } = useAuth()
const [showCreds, setShowCreds] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [editOpen, setEditOpen] = useState(false)
const { mode, toggleMode } = useSimpleConfigMode(deviceId)
const { data: device, isLoading } = useQuery({
queryKey: ['device', tenantId, deviceId],
queryFn: () => devicesApi.get(tenantId, deviceId),
})
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
const { data: groups } = useQuery({
queryKey: ['device-groups', tenantId],
queryFn: () => deviceGroupsApi.list(tenantId),
enabled: canWrite(user),
})
const { data: tags } = useQuery({
queryKey: ['device-tags', tenantId],
queryFn: () => deviceTagsApi.list(tenantId),
enabled: canWrite(user),
})
const deleteMutation = useMutation({
mutationFn: () => devicesApi.delete(tenantId, deviceId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['devices', tenantId] })
void queryClient.invalidateQueries({ queryKey: ['tenants'] })
toast({ title: 'Device deleted' })
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
},
onError: () => toast({ title: 'Failed to delete device', variant: 'destructive' }),
})
const addToGroupMutation = useMutation({
mutationFn: (groupId: string) => devicesApi.addToGroup(tenantId, deviceId, groupId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
},
onError: () => toast({ title: 'Failed to add to group', variant: 'destructive' }),
})
const removeFromGroupMutation = useMutation({
mutationFn: (groupId: string) => devicesApi.removeFromGroup(tenantId, deviceId, groupId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
},
onError: () => toast({ title: 'Failed to remove from group', variant: 'destructive' }),
})
const addTagMutation = useMutation({
mutationFn: (tagId: string) => devicesApi.addTag(tenantId, deviceId, tagId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
},
onError: () => toast({ title: 'Failed to add tag', variant: 'destructive' }),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => devicesApi.removeTag(tenantId, deviceId, tagId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device', tenantId, deviceId] })
},
onError: () => toast({ title: 'Failed to remove tag', variant: 'destructive' }),
})
const handleDelete = () => {
if (confirm(`Delete device "${device?.hostname}"? This cannot be undone.`)) {
deleteMutation.mutate()
}
}
if (isLoading) {
return <DetailPageSkeleton />
}
if (!device) {
return <div className="text-text-muted text-sm">Device not found</div>
}
const deviceGroupIds = new Set(device.groups.map((g) => g.id))
const deviceTagIds = new Set(device.tags.map((t) => t.id))
const availableGroups = groups?.filter((g) => !deviceGroupIds.has(g.id)) ?? []
const availableTags = tags?.filter((t) => !deviceTagIds.has(t.id)) ?? []
return (
<div className={cn('space-y-4', mode === 'simple' ? 'max-w-5xl' : 'max-w-3xl')} data-testid="device-detail">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId}
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">{device.hostname}</span>
</div>
{/* Device header */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold" data-testid="device-hostname">{device.hostname}</h1>
<StatusBadge status={device.status} />
<TlsSecurityBadge tlsMode={device.tls_mode} />
</div>
<p className="font-mono text-sm text-text-secondary">{device.ip_address}</p>
</div>
<div className="flex items-center gap-3">
<SimpleModeToggle mode={mode} onModeChange={toggleMode} />
<div className="flex gap-2">
{canWrite(user) && (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)} data-testid="button-edit-device">
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)}
{canDelete(user) && (
<Button variant="destructive" size="sm" onClick={handleDelete} data-testid="button-delete-device">
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</div>
{/* Config View (Simple or Standard) */}
<SimpleConfigView
tenantId={tenantId}
deviceId={deviceId}
device={device}
mode={mode}
activeTab={activeTab}
onTabChange={setActiveTab}
onModeChange={toggleMode}
overviewContent={
<>
{/* Device info */}
<div className="rounded-lg border border-border bg-surface px-4 py-2">
<InfoRow label="Model" value={device.model} />
<InfoRow label="RouterOS" value={device.routeros_version} />
<InfoRow label="Firmware" value={device.firmware_version || 'N/A'} />
<InfoRow label="Uptime" value={formatUptime(device.uptime_seconds)} />
<InfoRow label="Last Seen" value={formatDateTime(device.last_seen)} />
<InfoRow label="Serial" value={device.serial_number || 'N/A'} />
<InfoRow label="API Port" value={`${device.api_port} (plain) / ${device.api_ssl_port} (TLS)`} />
<InfoRow
label="TLS Mode"
value={
<div className="flex items-center gap-2">
<TlsSecurityBadge tlsMode={device.tls_mode} />
{(user?.role === 'admin' || user?.role === 'super_admin') && (
<TlsModeSelector
tenantId={tenantId}
deviceId={device.id}
currentMode={device.tls_mode}
/>
)}
</div>
}
/>
<InfoRow label="Added" value={formatDate(device.created_at)} />
</div>
{/* Credentials (masked) */}
<div className="rounded-lg border border-border bg-surface px-4 py-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-text-secondary">Credentials</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreds((v) => !v)}
className="h-6 px-2 text-xs"
>
{showCreds ? (
<>
<EyeOff className="h-3 w-3" /> Hide
</>
) : (
<>
<Eye className="h-3 w-3" /> Reveal
</>
)}
</Button>
</div>
<div className="space-y-2 text-sm">
<div className="flex gap-4">
<span className="text-xs text-text-muted w-20">Username</span>
<span className="font-mono">
{showCreds ? '(stored \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
</span>
</div>
<div className="flex gap-4">
<span className="text-xs text-text-muted w-20">Password</span>
<span className="font-mono">
{showCreds ? '(encrypted at rest \u2014 not returned by API)' : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
</span>
</div>
</div>
</div>
{/* Groups */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-text-muted" />
<h3 className="text-sm font-medium text-text-secondary">Groups</h3>
</div>
<div className="flex flex-wrap gap-2">
{device.groups.map((group) => (
<div
key={group.id}
className="flex items-center gap-1 text-xs border border-border-bright rounded px-2 py-1"
>
{group.name}
{canWrite(user) && (
<button
onClick={() => removeFromGroupMutation.mutate(group.id)}
className="text-text-muted hover:text-text-secondary ml-1"
title="Remove from group"
>
&times;
</button>
)}
</div>
))}
{device.groups.length === 0 && (
<span className="text-xs text-text-muted">No groups assigned</span>
)}
</div>
{canWrite(user) && availableGroups.length > 0 && (
<div className="flex items-center gap-2">
<Select onValueChange={(id) => addToGroupMutation.mutate(id)}>
<SelectTrigger className="h-7 text-xs w-48">
<SelectValue placeholder="Add to group..." />
</SelectTrigger>
<SelectContent>
{availableGroups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Tags */}
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-text-muted" />
<h3 className="text-sm font-medium text-text-secondary">Tags</h3>
</div>
<div className="flex flex-wrap gap-2">
{device.tags.map((tag) => (
<div key={tag.id} className="flex items-center gap-1">
<Badge color={tag.color}>
{tag.name}
{canWrite(user) && (
<button
onClick={() => removeTagMutation.mutate(tag.id)}
className="ml-1 opacity-60 hover:opacity-100"
title="Remove tag"
>
&times;
</button>
)}
</Badge>
</div>
))}
{device.tags.length === 0 && (
<span className="text-xs text-text-muted">No tags assigned</span>
)}
</div>
{canWrite(user) && availableTags.length > 0 && (
<Select onValueChange={(id) => addTagMutation.mutate(id)}>
<SelectTrigger className="h-7 text-xs w-48">
<SelectValue placeholder="Add tag..." />
</SelectTrigger>
<SelectContent>
{availableTags.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Interface Utilization */}
<div className="rounded-lg border border-border bg-surface p-4">
<h3 className="text-sm font-medium text-muted-foreground mb-3">Interface Utilization</h3>
<InterfaceGauges tenantId={tenantId} deviceId={deviceId} active={activeTab === 'overview'} />
</div>
</>
}
alertsContent={
<DeviceAlertsSection tenantId={tenantId} deviceId={deviceId} active={activeTab === 'alerts'} />
}
/>
{canWrite(user) && (
<EditDeviceDialog
device={device}
tenantId={tenantId}
open={editOpen}
onOpenChange={setEditOpen}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Device Alerts Section
// ---------------------------------------------------------------------------
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
function SeverityBadge({ severity }: { severity: string }) {
const config: Record<string, string> = {
critical: 'bg-error/20 text-error border-error/40',
warning: 'bg-warning/20 text-warning border-warning/40',
info: 'bg-info/20 text-info border-info/40',
}
return (
<span
className={cn(
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
config[severity] ?? config.info,
)}
>
{severity}
</span>
)
}
function DeviceAlertsSection({
tenantId,
deviceId,
active,
}: {
tenantId: string
deviceId: string
active: boolean
}) {
const queryClient = useQueryClient()
const { user } = useAuth()
const [showResolved, setShowResolved] = useState(false)
const { data: alertsData, isLoading } = useQuery({
queryKey: ['device-alerts', tenantId, deviceId],
queryFn: () => alertsApi.getDeviceAlerts(tenantId, deviceId, { per_page: 20 }),
enabled: active,
refetchInterval: active ? 30_000 : undefined,
})
const acknowledgeMutation = useMutation({
mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
toast({ title: 'Alert acknowledged' })
},
})
const silenceMutation = useMutation({
mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) =>
alertsApi.silenceAlert(tenantId, alertId, minutes),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['device-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
toast({ title: 'Alert silenced' })
},
})
const alerts = alertsData?.items ?? []
const firingAlerts = alerts.filter((a) => a.status === 'firing')
const resolvedAlerts = alerts.filter((a) => a.status === 'resolved').slice(0, 5)
if (isLoading) {
return <TableSkeleton rows={3} />
}
return (
<div className="mt-4 space-y-4">
{/* Active alerts */}
<div>
<h3 className="text-sm font-medium text-text-secondary mb-2 flex items-center gap-2">
<BellRing className="h-4 w-4" />
Active Alerts
{firingAlerts.length > 0 && (
<span className="bg-error/20 text-error text-xs px-1.5 rounded-full">
{firingAlerts.length}
</span>
)}
</h3>
{firingAlerts.length === 0 ? (
<div className="rounded-lg border border-border bg-surface p-6 text-center">
<CheckCircle className="h-6 w-6 text-success/50 mx-auto mb-1" />
<p className="text-xs text-text-muted">No active alerts for this device.</p>
</div>
) : (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
{firingAlerts.map((alert) => {
const isSilenced =
alert.silenced_until && new Date(alert.silenced_until) > new Date()
return (
<div
key={alert.id}
className="flex items-center gap-3 px-4 py-3 border-b border-border/50 last:border-0"
>
<BellRing className="h-4 w-4 text-error flex-shrink-0" />
<SeverityBadge severity={alert.severity} />
<div className="flex-1 min-w-0">
<span className="text-sm text-text-primary truncate block">
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`}
</span>
<span className="text-xs text-text-muted">
{alert.rule_name && `${alert.rule_name}`}
{alert.threshold != null &&
`${alert.value != null ? Number(alert.value).toFixed(1) : '?'} / ${alert.threshold}`}
{' — '}
{timeAgo(alert.fired_at)}
{isSilenced && ' (silenced)'}
</span>
</div>
{!alert.acknowledged_at && canWrite(user) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => acknowledgeMutation.mutate(alert.id)}
>
Ack
</Button>
)}
{canWrite(user) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
<BellOff className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() =>
silenceMutation.mutate({ alertId: alert.id, minutes: 15 })
}
>
15 min
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
silenceMutation.mutate({ alertId: alert.id, minutes: 60 })
}
>
1 hour
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
silenceMutation.mutate({ alertId: alert.id, minutes: 240 })
}
>
4 hours
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
silenceMutation.mutate({ alertId: alert.id, minutes: 1440 })
}
>
24 hours
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
})}
</div>
)}
</div>
{/* Resolved alerts */}
{resolvedAlerts.length > 0 && (
<div>
<button
onClick={() => setShowResolved((v) => !v)}
className="text-sm font-medium text-text-muted hover:text-text-secondary flex items-center gap-2 mb-2"
>
<CheckCircle className="h-4 w-4" />
Recent Resolved ({resolvedAlerts.length})
<span className="text-xs">{showResolved ? '(hide)' : '(show)'}</span>
</button>
{showResolved && (
<div className="rounded-lg border border-border bg-surface overflow-hidden">
{resolvedAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-center gap-3 px-4 py-2 border-b border-border/50 last:border-0 opacity-60"
>
<CheckCircle className="h-3.5 w-3.5 text-success flex-shrink-0" />
<SeverityBadge severity={alert.severity} />
<span className="text-xs text-text-secondary flex-1 truncate">
{alert.message ?? alert.metric ?? 'System alert'}
</span>
<span className="text-xs text-text-muted">
{alert.resolved_at ? timeAgo(alert.resolved_at) : ''}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* Link to full alerts page */}
<div className="text-center">
<Link
to="/alerts"
className="text-xs text-info hover:text-accent"
>
View all alerts for this device
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { tenantsApi } from '@/lib/api'
import { AddDeviceForm } from '@/components/fleet/AddDeviceForm'
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/add')({
component: AddDevicePage,
})
function AddDevicePage() {
const { tenantId } = Route.useParams()
const navigate = useNavigate()
const [open, setOpen] = useState(true)
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
const handleClose = () => {
setOpen(false)
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
}
return (
<div className="space-y-4">
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId} Devices
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Add Device</span>
</div>
<AddDeviceForm tenantId={tenantId} open={open} onClose={handleClose} />
</div>
)
}

View File

@@ -0,0 +1,59 @@
/**
* Device Adoption page route -- /_authenticated/tenants/$tenantId/devices/adopt
*
* 5-step wizard for discovering, configuring, and importing MikroTik devices.
*/
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ChevronRight } from 'lucide-react'
import { tenantsApi } from '@/lib/api'
import { AdoptionWizard } from '@/components/fleet/AdoptionWizard'
export const Route = createFileRoute(
'/_authenticated/tenants/$tenantId/devices/adopt',
)({
component: AdoptPage,
})
function AdoptPage() {
const { tenantId } = Route.useParams()
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
return (
<div className="space-y-4 max-w-3xl">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId}
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
Devices
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Adopt Devices</span>
</div>
<h1 className="text-lg font-semibold">Adopt Devices</h1>
<AdoptionWizard tenantId={tenantId} />
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Plus, Scan, ChevronRight } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { tenantsApi } from '@/lib/api'
import { useAuth, canWrite } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { FleetTable } from '@/components/fleet/FleetTable'
import { DeviceFilters } from '@/components/fleet/DeviceFilters'
import { AddDeviceForm } from '@/components/fleet/AddDeviceForm'
const searchSchema = z.object({
search: z.string().optional(),
status: z.string().optional(),
sort_by: z.string().optional(),
sort_dir: z.enum(['asc', 'desc']).optional(),
page: z.number().int().positive().optional(),
page_size: z.number().int().positive().optional(),
})
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/')({
validateSearch: searchSchema,
component: DevicesPage,
})
function DevicesPage() {
const { tenantId } = Route.useParams()
const search = Route.useSearch()
const { user } = useAuth()
const [addOpen, setAddOpen] = useState(false)
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
return (
<div className="space-y-3">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId}
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Devices</span>
</div>
{/* Header */}
<div className="flex items-center justify-between gap-4">
<DeviceFilters tenantId={tenantId} />
{canWrite(user) && (
<div className="flex items-center gap-2 flex-shrink-0">
<Link to="/tenants/$tenantId/devices/scan" params={{ tenantId }}>
<Button variant="outline" size="sm">
<Scan className="h-3.5 w-3.5" />
Scan Subnet
</Button>
</Link>
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Add Device
</Button>
</div>
)}
</div>
<FleetTable
tenantId={tenantId}
search={search.search}
status={search.status}
sortBy={search.sort_by}
sortDir={search.sort_dir}
page={search.page}
pageSize={search.page_size}
/>
<AddDeviceForm tenantId={tenantId} open={addOpen} onClose={() => setAddOpen(false)} />
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { tenantsApi, type SubnetScanResponse } from '@/lib/api'
import { SubnetScanForm } from '@/components/fleet/SubnetScanForm'
import { ScanResultsList } from '@/components/fleet/ScanResultsList'
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/devices/scan')({
component: ScanPage,
})
function ScanPage() {
const { tenantId } = Route.useParams()
const navigate = useNavigate()
const [results, setResults] = useState<SubnetScanResponse | null>(null)
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
const handleDone = () => {
void navigate({ to: '/tenants/$tenantId/devices', params: { tenantId } })
}
return (
<div className="space-y-4 max-w-3xl">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId}
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
Devices
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Scan Subnet</span>
</div>
<SubnetScanForm tenantId={tenantId} onResults={setResults} />
{results && (
<ScanResultsList tenantId={tenantId} results={results} onDone={handleDone} />
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Users, Monitor, Building2 } from 'lucide-react'
import { tenantsApi } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import { CardGridSkeleton } from '@/components/ui/page-skeleton'
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/')({
component: TenantDetailPage,
})
function TenantDetailPage() {
const { tenantId } = Route.useParams()
const { data: tenant, isLoading } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
if (isLoading) {
return <CardGridSkeleton cards={2} />
}
if (!tenant) {
return <div className="text-text-muted text-sm">Tenant not found</div>
}
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-elevated flex items-center justify-center flex-shrink-0">
<Building2 className="h-5 w-5 text-text-secondary" />
</div>
<div>
<h1 className="text-base font-semibold">{tenant.name}</h1>
{tenant.description && (
<p className="text-sm text-text-secondary mt-0.5">{tenant.description}</p>
)}
<p className="text-xs text-text-muted mt-1">Created {formatDate(tenant.created_at)}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Link
to="/tenants/$tenantId/users"
params={{ tenantId }}
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-4 hover:bg-elevated/50 transition-colors group"
>
<Users className="h-8 w-8 text-text-muted group-hover:text-text-muted transition-colors" />
<div>
<p className="text-2xl font-semibold">{tenant.user_count}</p>
<p className="text-xs text-text-secondary">Users</p>
</div>
</Link>
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="flex items-center gap-3 rounded-lg border border-border bg-surface p-4 hover:bg-elevated/50 transition-colors group"
>
<Monitor className="h-8 w-8 text-text-muted group-hover:text-text-muted transition-colors" />
<div>
<p className="text-2xl font-semibold">{tenant.device_count}</p>
<p className="text-xs text-text-secondary">Devices</p>
</div>
</Link>
</div>
<div className="flex gap-2">
<Link
to="/tenants/$tenantId/users"
params={{ tenantId }}
className="text-sm text-text-secondary hover:text-text-primary transition-colors underline-offset-2 hover:underline"
>
Manage users
</Link>
<span className="text-text-muted">·</span>
<Link
to="/tenants/$tenantId/devices"
params={{ tenantId }}
className="text-sm text-text-secondary hover:text-text-primary transition-colors underline-offset-2 hover:underline"
>
View fleet
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { tenantsApi } from '@/lib/api'
import { UserList } from '@/components/users/UserList'
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/users')({
component: UsersPage,
})
function UsersPage() {
const { tenantId } = Route.useParams()
const { data: tenant } = useQuery({
queryKey: ['tenants', tenantId],
queryFn: () => tenantsApi.get(tenantId),
})
return (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<Link to="/tenants" className="hover:text-text-secondary transition-colors">
Tenants
</Link>
<ChevronRight className="h-3 w-3" />
<Link
to="/tenants/$tenantId"
params={{ tenantId }}
className="hover:text-text-secondary transition-colors"
>
{tenant?.name ?? tenantId}
</Link>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Users</span>
</div>
<UserList tenantId={tenantId} />
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { TenantList } from '@/components/tenants/TenantList'
export const Route = createFileRoute('/_authenticated/tenants/')({
component: TenantsPage,
})
function TenantsPage() {
return <TenantList />
}

View File

@@ -0,0 +1,59 @@
/**
* Topology page route -- /_authenticated/topology
*
* Renders a full-height reactflow network topology map for the user's tenant.
* Uses the global org selector in the header for tenant context.
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, Network, ChevronRight } from 'lucide-react'
import { useAuth, isSuperAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { TopologyMap } from '@/components/network/TopologyMap'
export const Route = createFileRoute('/_authenticated/topology')({
component: TopologyPage,
})
function TopologyPage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
return (
<div className="flex flex-col h-[calc(100vh-3rem)]">
{/* Header */}
<div className="flex-shrink-0 px-4 pt-4 pb-3 space-y-3">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-xs text-text-muted">
<span>Home</span>
<ChevronRight className="h-3 w-3" />
<span className="text-text-secondary">Topology</span>
</div>
{/* Title */}
<h1 className="text-lg font-semibold flex items-center gap-2">
<Network className="h-5 w-5 text-text-muted" />
Network Topology
</h1>
</div>
{/* Topology map (full remaining height) */}
<div className="flex-1 min-h-0 mx-4 mb-4 rounded-lg border border-border bg-surface overflow-hidden">
{tenantId ? (
<TopologyMap tenantId={tenantId} />
) : (
<div className="flex flex-col items-center justify-center h-full">
<Building2 className="h-8 w-8 text-text-muted mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view the network topology.
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
/**
* Data Transparency page route -- /_authenticated/transparency
*
* Displays a filterable timeline of every KMS credential access event.
* Uses the global org selector in the header for tenant context.
* Admin-only access (tenant_admin, super_admin).
*
* Phase 31 -- TRUST-01, TRUST-02
*/
import { createFileRoute } from '@tanstack/react-router'
import { Building2, Eye } from 'lucide-react'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { TransparencyLogTable } from '@/components/transparency/TransparencyLogTable'
export const Route = createFileRoute('/_authenticated/transparency')({
component: TransparencyPage,
})
function TransparencyPage() {
const { user } = useAuth()
const isSuper = isSuperAdmin(user)
const isAdmin = isTenantAdmin(user)
const { selectedTenantId } = useUIStore()
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
// RBAC: require at least admin role (tenant_admin or super_admin)
if (!isAdmin) {
return (
<div className="max-w-6xl space-y-4">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Eye className="h-5 w-5 text-text-muted" />
Data Transparency
</h1>
<div className="rounded-lg border border-border bg-surface p-8 text-center">
<p className="text-sm text-text-muted">
You need admin permissions to view the transparency dashboard.
</p>
</div>
</div>
)
}
return (
<div className="max-w-6xl space-y-4">
{/* Title */}
<div>
<h1 className="text-lg font-semibold flex items-center gap-2">
<Eye className="h-5 w-5 text-text-muted" />
Data Transparency
</h1>
<p className="text-sm text-text-muted mt-0.5">
Track every credential access event across your devices
</p>
</div>
{/* Transparency log table or empty state */}
{tenantId ? (
<TransparencyLogTable tenantId={tenantId} />
) : (
<div className="rounded-lg border border-border bg-surface p-12 text-center">
<Building2 className="h-8 w-8 text-text-muted mx-auto mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view transparency logs.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { VpnPage } from '@/components/vpn/VpnPage'
export const Route = createFileRoute('/_authenticated/vpn')({
component: VpnPage,
})