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,25 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/error-boundary'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
function RootComponent() {
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFoundPage,
})

View File

@@ -0,0 +1,231 @@
import { useCallback } from 'react'
import { createFileRoute, Outlet, Navigate, redirect, useNavigate, useRouterState } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import { toast } from 'sonner'
import { useAuth } from '@/lib/auth'
import { useUIStore } from '@/lib/store'
import { useEventStream, type SSEEvent } from '@/hooks/useEventStream'
import { usePageTitle } from '@/hooks/usePageTitle'
import { useSequenceShortcut } from '@/hooks/useShortcut'
import { EventStreamProvider } from '@/contexts/EventStreamContext'
import type { FleetDevice } from '@/lib/api'
import { AppLayout } from '@/components/layout/AppLayout'
import { PageTransition } from '@/components/layout/PageTransition'
import { Skeleton } from '@/components/ui/skeleton'
import { ErrorBoundary } from '@/components/ui/error-boundary'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => {
const { isAuthenticated } = useAuth.getState()
if (!isAuthenticated) {
throw redirect({ to: '/login' })
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const { isAuthenticated, isLoading, user } = useAuth()
const routerState = useRouterState()
const pageKey = routerState.location.pathname
const queryClient = useQueryClient()
const nav = useNavigate()
usePageTitle()
const isSuperAdmin = user?.role === 'super_admin'
const selectedTenantId = useUIStore((s) => s.selectedTenantId)
// For regular users, use their tenant_id. For super_admin, use selectedTenantId.
const effectiveTenantId = isSuperAdmin ? selectedTenantId : (user?.tenant_id ?? null)
// Fleet summary query key must match FleetDashboard exactly
const fleetSummaryKey = ['fleet-summary', isSuperAdmin ? 'all' : (effectiveTenantId ?? '')]
const onEvent = useCallback(
(event: SSEEvent) => {
switch (event.type) {
// ── Device status changes (RT-02) ─────────────────────────────────
case 'device_status': {
const { device_id, status, device_name } = event.data as {
device_id: string
status: string
device_name?: string
}
// Optimistic update in fleet summary cache
queryClient.setQueryData<FleetDevice[]>(fleetSummaryKey, (old) => {
if (!old) return old
return old.map((d) =>
d.id === device_id
? { ...d, status, last_seen: new Date().toISOString() }
: d,
)
})
// Invalidate device detail queries
void queryClient.invalidateQueries({ queryKey: ['device', device_id] })
// Invalidate fleet-devices keys used by other components
void queryClient.invalidateQueries({ queryKey: ['fleet-devices'] })
void (() => device_name)() // suppress unused lint (used only in data payload)
break
}
// ── Alert fired (RT-03) ───────────────────────────────────────────
case 'alert_fired': {
const { severity, rule_name, device_name, metric, current_value, threshold } =
event.data as {
severity: string
rule_name: string
device_name?: string
metric?: string
current_value?: string | number
threshold?: string | number
}
const toastFn =
severity === 'critical'
? toast.error
: severity === 'warning'
? toast.warning
: toast.info
toastFn(`Alert: ${rule_name}`, {
description: device_name
? `${device_name}${metric ?? 'unknown'}: ${current_value ?? '?'} (threshold: ${threshold ?? '?'})`
: `${metric ?? 'unknown'}: ${current_value ?? '?'}`,
duration: severity === 'critical' ? 10000 : 5000,
})
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
break
}
// ── Alert resolved ────────────────────────────────────────────────
case 'alert_resolved': {
const { metric } = event.data as {
device_id?: string
metric?: string
}
toast.info('Alert resolved', {
description: `${metric ?? 'Condition'} returned to normal`,
duration: 3000,
})
void queryClient.invalidateQueries({ queryKey: ['active-alerts'] })
void queryClient.invalidateQueries({ queryKey: ['alert-events'] })
void queryClient.invalidateQueries({ queryKey: ['dashboard-alerts'] })
break
}
// ── Config push progress (RT-04) ──────────────────────────────────
case 'config_push': {
const { device_id, stage, message } = event.data as {
device_id: string
stage: string
message?: string
}
window.dispatchEvent(
new CustomEvent('config-push-progress', {
detail: { device_id, stage, message },
}),
)
// On terminal states, invalidate config backup queries
if (['committed', 'reverted', 'failed'].includes(stage)) {
void queryClient.invalidateQueries({
queryKey: ['config-backups', device_id],
})
}
break
}
// ── Firmware upgrade progress (RT-05) ─────────────────────────────
case 'firmware_progress': {
const { job_id, device_id, stage, message, target_version } = event.data as {
job_id?: string
device_id?: string
stage: string
message?: string
target_version?: string
}
window.dispatchEvent(
new CustomEvent('firmware-progress', {
detail: { job_id, device_id, stage, message, target_version },
}),
)
// Invalidate firmware job queries so polling-based components also update
if (job_id) {
void queryClient.invalidateQueries({
queryKey: ['upgrade-job'],
})
}
if (['completed', 'failed'].includes(stage)) {
void queryClient.invalidateQueries({ queryKey: ['firmware-overview'] })
void queryClient.invalidateQueries({ queryKey: ['upgrade-jobs'] })
}
break
}
// ── Metric updates ────────────────────────────────────────────────
case 'metric_update': {
void queryClient.invalidateQueries({ queryKey: ['fleet-summary'] })
void queryClient.invalidateQueries({ queryKey: ['fleet-devices'] })
break
}
}
},
[queryClient, fleetSummaryKey],
)
// Only connect SSE when authenticated and we have a tenant context
const sseEnabled = isAuthenticated && !isLoading
const { connectionState, lastConnectedAt, reconnect } = useEventStream(
sseEnabled ? effectiveTenantId : null,
onEvent,
)
// ── Global navigation shortcuts (g + key) ──────────────────────────────────
const shortcutsEnabled = isAuthenticated && !isLoading
useSequenceShortcut(['g', 'd'], () => void nav({ to: '/' }), shortcutsEnabled)
useSequenceShortcut(['g', 'a'], () => void nav({ to: '/alerts' }), shortcutsEnabled)
useSequenceShortcut(['g', 't'], () => void nav({ to: '/topology' }), shortcutsEnabled)
useSequenceShortcut(['g', 'f'], () => void nav({ to: '/firmware' }), shortcutsEnabled)
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="space-y-4 w-64">
<Skeleton className="h-8 w-48 mx-auto" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" />
}
// Hide sidebar/header during setup wizard for a focused experience
const isSetup = pageKey === '/setup'
return (
<EventStreamProvider
connectionState={connectionState}
lastConnectedAt={lastConnectedAt}
reconnect={reconnect}
>
<ErrorBoundary>
{isSetup ? (
<Outlet />
) : (
<AppLayout>
<AnimatePresence mode="wait">
<PageTransition pageKey={pageKey}>
<Outlet />
</PageTransition>
</AnimatePresence>
</AppLayout>
)}
</ErrorBoundary>
</EventStreamProvider>
)
}

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,
})

View File

@@ -0,0 +1,110 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/lib/api'
import { RugLogo } from '@/components/brand/RugLogo'
export const Route = createFileRoute('/forgot-password')({
component: ForgotPasswordPage,
})
function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [submitting, setSubmitting] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
setSubmitting(true)
setError(null)
try {
await authApi.forgotPassword(email)
setSent(true)
} catch {
setError('Something went wrong. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm">
{/* Logo / branding */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center mb-4">
<RugLogo size={48} />
</div>
<h1 className="text-lg font-semibold text-text-primary">Reset Password</h1>
<p className="text-xs text-text-muted mt-1">
Enter your email to receive a reset link.
After resetting, you will set up zero-knowledge authentication.
</p>
</div>
<div className="rounded-lg border border-border bg-surface/50 p-6">
{sent ? (
<div className="space-y-4">
<div className="rounded-md bg-success/10 border border-success/30 px-3 py-3">
<p className="text-sm text-success">
If an account with that email exists, a password reset link has been sent.
Check your inbox.
</p>
</div>
<Link
to="/login"
className="block text-center text-sm text-accent hover:underline"
>
Back to Sign In
</Link>
</div>
) : (
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (error) setError(null)
}}
placeholder="admin@example.com"
autoComplete="email"
autoFocus
required
/>
</div>
{error && (
<div className="rounded-md bg-error/10 border border-error/30 px-3 py-2">
<p className="text-xs text-error">{error}</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={submitting || !email}
>
{submitting ? 'Sending...' : 'Send Reset Link'}
</Button>
<Link
to="/login"
className="block text-center text-sm text-text-muted hover:text-text-primary"
>
Back to Sign In
</Link>
</form>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SecretKeyInput } from '@/components/auth/SecretKeyInput'
import { SrpUpgradeDialog } from '@/components/auth/SrpUpgradeDialog'
import { RugLogo } from '@/components/brand/RugLogo'
export const Route = createFileRoute('/login')({
component: LoginPage,
})
function LoginPage() {
const {
login,
srpLogin,
isAuthenticated,
isLoading,
error,
clearError,
needsSecretKey,
isDerivingKeys,
clearNeedsSecretKey,
isUpgrading,
pendingUpgradeEmail,
pendingUpgradePassword,
completeUpgrade,
cancelUpgrade,
} = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [secretKey, setSecretKey] = useState('')
const [submitting, setSubmitting] = useState(false)
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated && !isLoading) {
void navigate({ to: '/' })
}
}, [isAuthenticated, isLoading, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !password) return
setSubmitting(true)
try {
if (needsSecretKey) {
// SRP user providing Secret Key on new device
await srpLogin(email, password, secretKey)
} else {
// Normal login -- will auto-redirect to SRP if user is SRP-enrolled
await login(email, password)
}
// Don't navigate if SRP upgrade or Secret Key entry is needed
const { isUpgrading: upgrading, needsSecretKey: needsKey } = useAuth.getState()
if (!upgrading && !needsKey) {
void navigate({ to: '/' })
}
} catch {
// error handled by useAuth
} finally {
setSubmitting(false)
}
}
const handleChange = () => {
if (error) clearError()
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
// Only clear Secret Key state if email actually changed to a different address
if (needsSecretKey && newEmail.toLowerCase() !== email.toLowerCase()) {
clearNeedsSecretKey()
setSecretKey('')
}
setEmail(newEmail)
handleChange()
}
const buttonText = () => {
if (isDerivingKeys) return 'Unlocking your vault...'
if (submitting) return 'Signing in...'
if (needsSecretKey) return 'Unlock'
return 'Sign In'
}
return (
<div className="flex min-h-screen items-center justify-center bg-background relative overflow-hidden" data-testid="login-page">
{/* Decorative rug border accent */}
<div
className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[400px] pointer-events-none opacity-[0.04]"
style={{
backgroundImage: `repeating-linear-gradient(
90deg,
#8B1A1A 0px, #8B1A1A 2px,
transparent 2px, transparent 8px,
#F5E6C8 8px, #F5E6C8 10px,
transparent 10px, transparent 16px,
#2A9D8F 16px, #2A9D8F 18px,
transparent 18px, transparent 24px
),
repeating-linear-gradient(
0deg,
#8B1A1A 0px, #8B1A1A 2px,
transparent 2px, transparent 8px,
#F5E6C8 8px, #F5E6C8 10px,
transparent 10px, transparent 16px,
#2A9D8F 16px, #2A9D8F 18px,
transparent 18px, transparent 24px
)`,
}}
/>
<div className="w-full max-w-sm relative z-10">
{/* Logo / branding */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center mb-4">
<RugLogo size={56} />
</div>
<h1 className="text-lg font-semibold text-text-primary">TOD - The Other Dude</h1>
<p className="text-xs text-text-muted mt-1">MSP Fleet Management</p>
</div>
{/* Login card */}
<div className="rounded-lg border border-border bg-surface/50 p-6">
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={handleEmailChange}
placeholder="admin@example.com"
autoComplete="email"
autoFocus
required
data-testid="input-email"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
to="/forgot-password"
className="text-xs text-text-muted hover:text-accent"
tabIndex={-1}
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
handleChange()
}}
placeholder="--------"
autoComplete="current-password"
required
data-testid="input-password"
/>
</div>
{/* Secret Key input -- shown when SRP user on new device */}
{needsSecretKey && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="secret-key">Secret Key</Label>
<span
className="text-xs text-text-muted cursor-help"
title="Enter the Secret Key from your Emergency Kit. This key was generated when you set up zero-knowledge encryption."
>
From your Emergency Kit
</span>
</div>
<SecretKeyInput
value={secretKey}
onChange={(v) => {
setSecretKey(v)
handleChange()
}}
error={!!error}
/>
<p className="text-xs text-text-muted">
This device does not have your Secret Key stored. Enter it from your Emergency
Kit to unlock your vault.
</p>
<details className="text-xs text-text-muted mt-1">
<summary className="cursor-pointer hover:text-accent">
Lost your Secret Key?
</summary>
<p className="mt-1.5 pl-3 border-l-2 border-border">
Use{' '}
<Link to="/forgot-password" className="text-accent hover:underline">
Forgot password
</Link>{' '}
to reset your account. You will set a new password and receive a new Secret Key.
Note: previously encrypted data may be inaccessible.
</p>
</details>
</div>
)}
{error && (
<div className="rounded-md bg-error/10 border border-error/30 px-3 py-2" data-testid="login-error">
<p className="text-xs text-error">{error}</p>
</div>
)}
<Button
type="submit"
className="w-full"
data-testid="button-sign-in"
disabled={
submitting ||
isLoading ||
isDerivingKeys ||
!email ||
!password ||
(needsSecretKey && secretKey.replace(/[-\s]/g, '').length < 28)
}
>
{buttonText()}
</Button>
</form>
</div>
{/* First-run hint */}
<div className="text-center mt-4 px-2">
<p className="text-xs text-text-muted">
First time? Use the credentials from your <code className="text-text-secondary">.env</code> file
(<code className="text-text-secondary">FIRST_ADMIN_EMAIL</code> / <code className="text-text-secondary">FIRST_ADMIN_PASSWORD</code>).
</p>
</div>
{/* Legal links */}
<div className="flex justify-center gap-4 mt-3">
<Link to="/terms" className="text-xs text-text-muted hover:text-text-secondary">
Terms of Service
</Link>
<Link to="/privacy" className="text-xs text-text-muted hover:text-text-secondary">
Privacy Policy
</Link>
</div>
</div>
{/* SRP Upgrade Dialog for legacy bcrypt users */}
{isUpgrading && pendingUpgradeEmail && pendingUpgradePassword && (
<SrpUpgradeDialog
open={isUpgrading}
email={pendingUpgradeEmail}
password={pendingUpgradePassword}
onComplete={async () => {
await completeUpgrade()
void navigate({ to: '/' })
}}
onCancel={cancelUpgrade}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,255 @@
import { createFileRoute, Link } from '@tanstack/react-router'
export const Route = createFileRoute('/privacy')({
component: PrivacyPage,
})
function PrivacyPage() {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto px-6 py-12">
<Link to="/login" className="text-sm text-accent hover:underline mb-8 inline-block">
&larr; Back
</Link>
<h1 className="text-2xl font-bold text-text-primary mb-2">Privacy Policy</h1>
<p className="text-sm text-text-muted mb-8">Last updated: March 2026</p>
<div className="prose prose-sm dark:prose-invert max-w-none space-y-6 text-text-secondary">
<section>
<h2 className="text-lg font-semibold text-text-primary">1. Overview</h2>
<p>
The Other Dude is self-hosted software. All data is stored on infrastructure
you own and control. The authors do not collect, transmit, or have access to any
of your data. This privacy policy describes what data the Software stores locally
on your deployment.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">2. Data We Store</h2>
<p>When deployed, The Other Dude stores the following data in your local database:</p>
<ul className="list-disc pl-6 space-y-1">
<li>
<strong>User accounts:</strong> Email addresses, names, and authentication data.
Passwords are never stored &mdash; The Other Dude uses SRP-6a zero-knowledge
authentication, meaning the server only stores a cryptographic verifier derived
from your password, never the password itself.
</li>
<li>
<strong>Encryption key material:</strong> Per-user encrypted key sets for
zero-knowledge encryption. Your Secret Key is stored only in your browser
(IndexedDB) and is never transmitted to or stored on the server.
</li>
<li>
<strong>Device credentials:</strong> RouterOS usernames and passwords for managed
devices, encrypted at rest with AES-256-GCM via per-tenant envelope encryption.
</li>
<li>
<strong>Device data:</strong> Hostnames, IP addresses, firmware versions, hardware
models, and configuration backups retrieved from your MikroTik devices.
</li>
<li>
<strong>Metrics:</strong> Time-series performance data (CPU, memory, bandwidth,
wireless stats) collected from your devices by the polling service.
</li>
<li>
<strong>Audit logs:</strong> Records of user actions within the portal (logins,
configuration changes, device management operations). Encrypted at rest with
zero-knowledge encryption.
</li>
<li>
<strong>Certificates:</strong> TLS certificates and encrypted private keys generated
by the internal certificate authority for device API connections.
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">3. No External Transmission</h2>
<p>
The Other Dude does not send any data to external servers, analytics services,
or third parties. All communication occurs between the portal and your MikroTik
devices over your local or private network. The only outbound connections are:
</p>
<ul className="list-disc pl-6 space-y-1">
<li>
<strong>Firmware checks:</strong> Queries the MikroTik download server to check
for RouterOS updates (no device data is sent).
</li>
<li>
<strong>Email notifications:</strong> If configured, alert emails are sent via
your SMTP server. Only alert data you configure is included.
</li>
<li>
<strong>Webhook notifications:</strong> If configured, alert data is sent to
webhook URLs you specify.
</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">4. Multi-Tenancy</h2>
<p>
The Other Dude supports multiple tenants (organizations). Data isolation between
tenants is enforced at the database level using PostgreSQL Row-Level Security (RLS).
Each tenant can only access their own devices, users, and data. The super_admin
role has cross-tenant visibility for platform administration.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">5. Security Measures</h2>
<ul className="list-disc pl-6 space-y-1">
<li>SRP-6a zero-knowledge authentication (server never sees your password)</li>
<li>Zero-knowledge encryption for config backups and audit logs</li>
<li>Per-tenant envelope encryption via KMS</li>
<li>Device credentials encrypted with AES-256-GCM</li>
<li>JWT tokens with short expiry (15 minutes) in httpOnly cookies</li>
<li>Rate limiting on authentication endpoints</li>
<li>RBAC with four permission levels</li>
<li>Security headers (CSP, X-Frame-Options, HSTS)</li>
<li>Subresource Integrity (SRI) hashes on JavaScript bundles</li>
</ul>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">6. Your Rights</h2>
<p>
As a user of this deployment, you have the following rights regarding your personal data:
</p>
<ul className="list-disc pl-6 space-y-3">
<li>
<strong>Right of Access (Art. 15):</strong> You can view your account information
on the Settings page at any time.
</li>
<li>
<strong>Right to Data Portability (Art. 20):</strong> You can export all your
personal data in JSON format from Settings &gt; Export My Data.
</li>
<li>
<strong>Right to Erasure (Art. 17):</strong> You can permanently delete your
account and all associated personal data from Settings &gt; Delete Account.
This action:
<ul className="list-disc pl-6 mt-1 space-y-1">
<li>Hard-deletes your user account, encrypted key sets, and API keys</li>
<li>Anonymizes your entries in the audit log (removes email/name, retains action records)</li>
<li>Creates a deletion receipt for compliance verification</li>
<li>Is irreversible &mdash; there is no recovery after deletion</li>
</ul>
</li>
<li>
<strong>Right to Rectification (Art. 16):</strong> Contact your administrator
to update your account information.
</li>
</ul>
<p className="mt-3">
These rights can be exercised through the Settings page when logged in, or by
contacting your deployment administrator.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">7. Data Retention</h2>
<p className="mb-4">
The Other Dude applies the following data retention periods:
</p>
<div className="not-prose">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4 font-medium text-text-primary">Data Type</th>
<th className="text-left py-2 pr-4 font-medium text-text-primary">Retention Period</th>
<th className="text-left py-2 font-medium text-text-primary">Notes</th>
</tr>
</thead>
<tbody className="text-text-secondary">
<tr className="border-b border-border/50">
<td className="py-2 pr-4">User accounts</td>
<td className="py-2 pr-4">Until deleted</td>
<td className="py-2">Users can self-delete from Settings</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">Device metrics</td>
<td className="py-2 pr-4">90 days</td>
<td className="py-2">Automatically purged by TimescaleDB retention policy</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">Configuration backups</td>
<td className="py-2 pr-4">Indefinite</td>
<td className="py-2">Stored in git repositories on your server</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">Audit logs</td>
<td className="py-2 pr-4">Indefinite</td>
<td className="py-2">Anonymized on account deletion; action records retained</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">API keys</td>
<td className="py-2 pr-4">Until revoked or user deleted</td>
<td className="py-2">Cascade-deleted with user account</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">Encrypted key material</td>
<td className="py-2 pr-4">Until user deleted</td>
<td className="py-2">Cascade-deleted with user account</td>
</tr>
<tr className="border-b border-border/50">
<td className="py-2 pr-4">Session data (Redis)</td>
<td className="py-2 pr-4">15 min (access) / 7 days (refresh)</td>
<td className="py-2">Auto-expiring tokens</td>
</tr>
<tr>
<td className="py-2 pr-4">Password reset tokens</td>
<td className="py-2 pr-4">Until used or 30 minutes</td>
<td className="py-2">Auto-expire, cascade-deleted with user</td>
</tr>
</tbody>
</table>
</div>
<p className="mt-4">
After account deletion, all personally identifiable information is permanently
erased. Anonymized audit log entries (with no PII) are retained for security compliance.
</p>
<p>
After tenant deactivation, all tenant data (devices, metrics, configurations, user
accounts) is retained until the super admin explicitly deletes the tenant, at which
point all data is cascade-deleted.
</p>
<p>
You control all retention through your database and can adjust these periods in
your deployment configuration.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">8. Cookies</h2>
<p>
The Other Dude uses a single httpOnly session cookie for authentication. No
tracking cookies, analytics cookies, or third-party cookies are used. The
application also uses localStorage for user preferences (theme, sidebar state,
configuration mode).
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">9. Your Responsibilities</h2>
<p>
As the operator of a self-hosted deployment, you are the data controller. You are
responsible for compliance with applicable data protection laws (GDPR, CCPA, etc.)
in your jurisdiction, including data subject access requests and breach notification.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">10. Contact</h2>
<p>
For questions about this privacy policy or the data practices of The Other Dude,
please open an issue in the project repository.
</p>
</section>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,165 @@
import { createFileRoute, Link, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/lib/api'
import { RugLogo } from '@/components/brand/RugLogo'
export const Route = createFileRoute('/reset-password')({
component: ResetPasswordPage,
validateSearch: (search: Record<string, unknown>) => ({
token: (search.token as string) ?? '',
}),
})
function ResetPasswordPage() {
const { token } = useSearch({ from: '/reset-password' })
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const passwordsMatch = password === confirmPassword
const passwordLongEnough = password.length >= 8
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password || !confirmPassword || !passwordsMatch || !passwordLongEnough) return
setSubmitting(true)
setError(null)
try {
await authApi.resetPassword(token, password)
setSuccess(true)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
'Something went wrong. Please try again.'
setError(msg)
} finally {
setSubmitting(false)
}
}
if (!token) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm text-center">
<div className="rounded-lg border border-border bg-surface/50 p-6 space-y-4">
<p className="text-sm text-error">Invalid reset link. No token provided.</p>
<Link to="/login" className="block text-sm text-accent hover:underline">
Back to Sign In
</Link>
</div>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm">
{/* Logo / branding */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center mb-4">
<RugLogo size={48} />
</div>
<h1 className="text-lg font-semibold text-text-primary">Set New Password</h1>
<p className="text-xs text-text-muted mt-1">Choose a strong password</p>
</div>
<div className="rounded-lg border border-border bg-surface/50 p-6">
{success ? (
<div className="space-y-4">
<div className="rounded-md bg-success/10 border border-success/30 px-3 py-3">
<p className="text-sm text-success">
Password has been reset successfully.
</p>
</div>
<div className="rounded-md bg-accent/10 border border-accent/30 px-3 py-3">
<p className="text-sm text-text-secondary">
When you sign in, you will be guided through a one-time security upgrade
to zero-knowledge authentication. Your password will never be stored on
the server.
</p>
</div>
<Link
to="/login"
className="block text-center text-sm text-accent hover:underline"
>
Go to Sign In
</Link>
</div>
) : (
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (error) setError(null)
}}
placeholder="Minimum 8 characters"
autoComplete="new-password"
autoFocus
required
minLength={8}
/>
{password && !passwordLongEnough && (
<p className="text-xs text-error">Must be at least 8 characters</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
if (error) setError(null)
}}
placeholder="Re-enter password"
autoComplete="new-password"
required
minLength={8}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-error">Passwords do not match</p>
)}
</div>
{error && (
<div className="rounded-md bg-error/10 border border-error/30 px-3 py-2">
<p className="text-xs text-error">{error}</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={
submitting || !password || !confirmPassword || !passwordsMatch || !passwordLongEnough
}
>
{submitting ? 'Resetting...' : 'Reset Password'}
</Button>
<Link
to="/login"
className="block text-center text-sm text-text-muted hover:text-text-primary"
>
Back to Sign In
</Link>
</form>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
import { createFileRoute, Link } from '@tanstack/react-router'
export const Route = createFileRoute('/terms')({
component: TermsPage,
})
function TermsPage() {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto px-6 py-12">
<Link to="/login" className="text-sm text-accent hover:underline mb-8 inline-block">
&larr; Back
</Link>
<h1 className="text-2xl font-bold text-text-primary mb-2">Terms of Service</h1>
<p className="text-sm text-text-muted mb-8">Last updated: March 2026</p>
<div className="prose prose-sm dark:prose-invert max-w-none space-y-6 text-text-secondary">
<section>
<h2 className="text-lg font-semibold text-text-primary">1. Acceptance of Terms</h2>
<p>
By accessing or using The Other Dude ("the Software"), you agree to be bound by
these Terms of Service. The Other Dude is open-source, self-hosted software
provided as-is for managing MikroTik RouterOS devices. If you do not agree to
these terms, do not use the Software.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">2. License</h2>
<p>
The Other Dude is provided under an open-source license. You are free to use,
modify, and distribute the Software in accordance with the license terms included
in the source repository. The Software is not affiliated with or endorsed by
MikroTik (SIA Mikrot&#299;kls).
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">3. Self-Hosted Deployment</h2>
<p>
The Other Dude is designed to be self-hosted. You are responsible for your own
deployment, including server security, database backups, network configuration,
and access control. The authors are not responsible for any data loss, security
breaches, or service interruptions that occur on your infrastructure.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">4. Device Management</h2>
<p>
The Software connects to MikroTik devices using the RouterOS API. Configuration
changes pushed through the portal use a two-phase commit with automatic rollback.
However, you acknowledge that managing network devices carries inherent risk, and
you are responsible for testing changes in a controlled environment before applying
them to production networks.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">5. Credentials and Security</h2>
<p>
Device credentials are encrypted at rest using AES-256-GCM. You are responsible
for securing your encryption keys, database access, and ensuring that only
authorized users have access to the portal. Never expose the portal to the public
internet without proper authentication and TLS.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">6. Disclaimer of Warranties</h2>
<p>
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE AUTHORS SHALL NOT BE LIABLE FOR ANY
CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">7. Limitation of Liability</h2>
<p>
In no event shall the authors be liable for any direct, indirect, incidental,
special, or consequential damages (including loss of data, revenue, or profit)
arising out of or in connection with the use of the Software, even if advised of
the possibility of such damages.
</p>
</section>
<section>
<h2 className="text-lg font-semibold text-text-primary">8. Changes to Terms</h2>
<p>
These terms may be updated from time to time. Continued use of the Software after
changes constitutes acceptance of the revised terms. Material changes will be
communicated through the project repository.
</p>
</section>
</div>
</div>
</div>
)
}