init ui
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# Frontend config for radui. Copy to `.env` and adjust as needed.
|
||||||
|
# NOTE: the API *key* is NOT set here — it is entered on the login screen and
|
||||||
|
# stored in the browser. Nothing in this file is secret.
|
||||||
|
|
||||||
|
# Dev only: where Vite proxies /api/* to (the radapi backend).
|
||||||
|
VITE_API_TARGET=http://10.0.1.235:8000
|
||||||
|
|
||||||
|
# Base path the client calls. Leave as /api to use the dev proxy (same-origin,
|
||||||
|
# no CORS). For a production build served on a different origin than the API,
|
||||||
|
# set this to the API's absolute URL, e.g. https://radapi.example.com
|
||||||
|
VITE_API_BASE=/api
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# radui — RADIUS Admin Portal
|
||||||
|
|
||||||
|
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **devices** and
|
||||||
|
**VLANs** without touching SQL. Auth is an **API key** entered on a login screen and
|
||||||
|
stored in the browser (`localStorage`), sent as `X-API-Key` on every request.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 19 + Vite + TypeScript
|
||||||
|
- Tailwind v4 + shadcn/ui-style components (Radix primitives)
|
||||||
|
- react-router-dom, sonner (toasts)
|
||||||
|
- Node provided via `shell.nix` (NixOS) — no global install needed
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell # drops you into a shell with node 22 + npm
|
||||||
|
npm install # first time only
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as a one-liner without entering the shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell --run "npm run dev -- --host 0.0.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Talking to the API
|
||||||
|
|
||||||
|
In dev, Vite proxies `/api/*` to the backend so the browser makes same-origin
|
||||||
|
requests (no CORS) and the key is only ever sent as a header. The target defaults
|
||||||
|
to `http://10.0.1.235:8000`; override it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_TARGET=http://192.168.1.21:8000 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in with the API key configured in radapi's `.env` (`API_KEY`). A `401` from any
|
||||||
|
request clears the stored key and returns you to the login screen.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # tsc -b && vite build → dist/
|
||||||
|
npm run preview # serve the production build locally
|
||||||
|
```
|
||||||
|
|
||||||
|
For production the built `dist/` is static — serve it behind the same origin as the
|
||||||
|
API (or set `VITE_API_BASE` to the API's absolute URL at build time).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.tsx providers (router, auth, toaster)
|
||||||
|
App.tsx auth gate + nav + routes
|
||||||
|
auth/auth.tsx API-key auth context (login/logout, 401 handling)
|
||||||
|
lib/api.ts typed API client (device + vlan) + error handling
|
||||||
|
lib/utils.ts cn() helper
|
||||||
|
pages/
|
||||||
|
Login.tsx API-key login screen
|
||||||
|
Devices.tsx device list + add/edit/delete dialogs
|
||||||
|
Vlans.tsx VLAN list + add/rename/delete dialogs
|
||||||
|
components/ui/ button, input, label, dialog, select, table, card, badge, sonner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Add device** requires MAC, group (VLAN), name, phone; alias optional. The group
|
||||||
|
dropdown is populated from `GET /vlan/`.
|
||||||
|
- **Edit device** can change group, status, name, phone, alias (any subset).
|
||||||
|
- Validation/`4xx` errors from the API surface as toasts with the server's message
|
||||||
|
(422 field errors are flattened to `field: message`).
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RADIUS Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2906
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "radui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^4.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.nodejs_22
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "radui dev shell — node $(node --version), npm $(npm --version)"
|
||||||
|
'';
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { LogOut, Router, Wifi } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/auth/auth'
|
||||||
|
import { Login } from '@/pages/Login'
|
||||||
|
import { Devices } from '@/pages/Devices'
|
||||||
|
import { Vlans } from '@/pages/Vlans'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Nav() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b bg-background">
|
||||||
|
<div className="mx-auto flex h-14 max-w-6xl items-center gap-1 px-4">
|
||||||
|
<div className="mr-4 flex items-center gap-2 font-semibold">
|
||||||
|
<Wifi className="h-5 w-5 text-primary" />
|
||||||
|
RADIUS Admin
|
||||||
|
</div>
|
||||||
|
<NavLink to="/devices" className={linkClass}>
|
||||||
|
<Router className="h-4 w-4" /> Devices
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/vlans" className={linkClass}>
|
||||||
|
<Wifi className="h-4 w-4" /> VLANs
|
||||||
|
</NavLink>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-auto text-muted-foreground" onClick={logout}>
|
||||||
|
<LogOut className="h-4 w-4" /> Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { authenticated } = useAuth()
|
||||||
|
|
||||||
|
if (!authenticated) return <Login />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20">
|
||||||
|
<Nav />
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/devices" element={<Devices />} />
|
||||||
|
<Route path="/vlans" element={<Vlans />} />
|
||||||
|
<Route path="*" element={<Navigate to="/devices" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
clearApiKey,
|
||||||
|
getApiKey,
|
||||||
|
setApiKey,
|
||||||
|
setUnauthorizedHandler,
|
||||||
|
} from '@/lib/api'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
authenticated: boolean
|
||||||
|
login: (key: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [authenticated, setAuthenticated] = useState(() => !!getApiKey())
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearApiKey()
|
||||||
|
setAuthenticated(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Any 401 from the API layer forces a logout so the login screen reappears.
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(logout)
|
||||||
|
}, [logout])
|
||||||
|
|
||||||
|
const login = useCallback(async (key: string) => {
|
||||||
|
setApiKey(key)
|
||||||
|
try {
|
||||||
|
await api.ping() // validates the key; throws ApiError(401) if wrong
|
||||||
|
setAuthenticated(true)
|
||||||
|
} catch (err) {
|
||||||
|
clearApiKey()
|
||||||
|
setAuthenticated(false)
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
throw new Error('That API key was rejected.')
|
||||||
|
}
|
||||||
|
throw err instanceof Error ? err : new Error('Could not reach the API.')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <AuthContext value={{ authenticated, login, logout }}>{children}</AuthContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success: 'border-transparent bg-emerald-100 text-emerald-800',
|
||||||
|
warning: 'border-transparent bg-amber-100 text-amber-800',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/50 backdrop-blur-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||||
|
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn('p-1', position === 'popper' && 'w-full min-w-[var(--radix-select-trigger-width)]')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Toaster as Sonner } from 'sonner'
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
function Toaster(props: ToasterProps) {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
className="toaster group"
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Table.displayName = 'Table'
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = 'TableHeader'
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableBody.displayName = 'TableBody'
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TableRow.displayName = 'TableRow'
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = 'TableHead'
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||||
|
))
|
||||||
|
TableCell.displayName = 'TableCell'
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
// Typed client for the radapi FreeRADIUS REST API.
|
||||||
|
// Base URL is /api in dev (proxied by Vite to the real API) so the browser
|
||||||
|
// makes same-origin requests and the API key travels only as a header.
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE ?? '/api'
|
||||||
|
const KEY_STORAGE = 'radui.apiKey'
|
||||||
|
|
||||||
|
// ---- API key storage ----
|
||||||
|
export function getApiKey(): string | null {
|
||||||
|
return localStorage.getItem(KEY_STORAGE)
|
||||||
|
}
|
||||||
|
export function setApiKey(key: string) {
|
||||||
|
localStorage.setItem(KEY_STORAGE, key)
|
||||||
|
}
|
||||||
|
export function clearApiKey() {
|
||||||
|
localStorage.removeItem(KEY_STORAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 401 anywhere means the stored key is bad — notify the app to log out.
|
||||||
|
let onUnauthorized: (() => void) | null = null
|
||||||
|
export function setUnauthorizedHandler(fn: () => void) {
|
||||||
|
onUnauthorized = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.status = status
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationItem = { loc: (string | number)[]; msg: string }
|
||||||
|
|
||||||
|
function messageFromDetail(detail: unknown, fallback: string): string {
|
||||||
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return (detail as ValidationItem[])
|
||||||
|
.map((d) => {
|
||||||
|
const field = d.loc?.filter((p) => p !== 'body').join('.')
|
||||||
|
return field ? `${field}: ${d.msg}` : d.msg
|
||||||
|
})
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const key = getApiKey()
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
if (key) headers.set('X-API-Key', key)
|
||||||
|
if (options.body) headers.set('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
onUnauthorized?.()
|
||||||
|
throw new ApiError(401, 'Invalid or missing API key')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
|
let payload: unknown = null
|
||||||
|
const text = await res.text()
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
payload = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = (payload as { detail?: unknown })?.detail ?? payload
|
||||||
|
throw new ApiError(res.status, messageFromDetail(detail, `Request failed (${res.status})`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
export type DeviceStatus = 'new' | 'paid' | 'unpaid'
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
mac_address: string
|
||||||
|
group: string | null
|
||||||
|
status: DeviceStatus | null
|
||||||
|
name: string | null
|
||||||
|
phone: string | null
|
||||||
|
alias: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceCreate {
|
||||||
|
mac_address: string
|
||||||
|
group: string
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
alias?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceEdit {
|
||||||
|
mac_address: string
|
||||||
|
group?: string
|
||||||
|
status?: DeviceStatus
|
||||||
|
name?: string
|
||||||
|
phone?: string
|
||||||
|
alias?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vlan {
|
||||||
|
alias: string
|
||||||
|
vlanid: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Page<T> {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Endpoints ----
|
||||||
|
export const api = {
|
||||||
|
// health check to validate the API key on login
|
||||||
|
ping: () => request<unknown>('/vlan/'),
|
||||||
|
|
||||||
|
devices: {
|
||||||
|
list: (limit = 50, offset = 0) =>
|
||||||
|
request<Page<Device>>(`/device/?limit=${limit}&offset=${offset}`),
|
||||||
|
get: (mac: string) => request<Device>(`/device/${encodeURIComponent(mac)}`),
|
||||||
|
add: (body: DeviceCreate) =>
|
||||||
|
request<Device>('/device/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
edit: (body: DeviceEdit) =>
|
||||||
|
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
remove: (mac: string) =>
|
||||||
|
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
|
||||||
|
vlans: {
|
||||||
|
list: () => request<Vlan[]>('/vlan/'),
|
||||||
|
add: (vlanid: number, alias: string) =>
|
||||||
|
request<Vlan>('/vlan/add', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
|
||||||
|
edit: (vlanid: number, alias: string) =>
|
||||||
|
request<Vlan>('/vlan/edit', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
|
||||||
|
remove: (vlanid: number) => request<void>(`/vlan/${vlanid}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './auth/auth.tsx'
|
||||||
|
import { Toaster } from './components/ui/sonner.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
const STATUSES: DeviceStatus[] = ['new', 'paid', 'unpaid']
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: DeviceStatus | null }) {
|
||||||
|
if (!status) return <span className="text-muted-foreground">—</span>
|
||||||
|
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
|
||||||
|
return <Badge variant={variant}>{status}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Devices() {
|
||||||
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
|
const [vlans, setVlans] = useState<Vlan[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<Device | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [d, v] = await Promise.all([api.devices.list(200), api.vlans.list()])
|
||||||
|
setDevices(d.items)
|
||||||
|
setVlans(v)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Devices</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus /> Add device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>MAC address</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Phone</TableHead>
|
||||||
|
<TableHead>Group</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Alias</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && devices.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
|
No devices yet. Add one to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
devices.map((d) => (
|
||||||
|
<TableRow key={d.mac_address}>
|
||||||
|
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
|
||||||
|
<TableCell>{d.name ?? '—'}</TableCell>
|
||||||
|
<TableCell>{d.phone ?? '—'}</TableCell>
|
||||||
|
<TableCell>{d.group ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={d.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleting(d)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddDeviceDialog
|
||||||
|
open={addOpen}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
vlans={vlans}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
<EditDeviceDialog
|
||||||
|
device={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
vlans={vlans}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
|
<DeleteDeviceDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Add ----------
|
||||||
|
function AddDeviceDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
vlans,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
vlans: Vlan[]
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [mac, setMac] = useState('')
|
||||||
|
const [group, setGroup] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [alias, setAlias] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMac('')
|
||||||
|
setGroup('')
|
||||||
|
setName('')
|
||||||
|
setPhone('')
|
||||||
|
setAlias('')
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const valid = mac.trim() && group && name.trim() && phone.trim()
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!valid) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.devices.add({
|
||||||
|
mac_address: mac.trim(),
|
||||||
|
group,
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
alias: alias.trim() || null,
|
||||||
|
})
|
||||||
|
toast.success(`Device ${mac.trim()} added`)
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to add device')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add device</DialogTitle>
|
||||||
|
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="MAC address" required>
|
||||||
|
<Input
|
||||||
|
placeholder="AA-BB-CC-DD-EE-FF"
|
||||||
|
className="font-mono"
|
||||||
|
value={mac}
|
||||||
|
onChange={(e) => setMac(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Group (VLAN)" required>
|
||||||
|
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Name" required>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Phone" required>
|
||||||
|
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Device alias (optional)">
|
||||||
|
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !valid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Add device
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Edit ----------
|
||||||
|
function EditDeviceDialog({
|
||||||
|
device,
|
||||||
|
onClose,
|
||||||
|
vlans,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
device: Device | null
|
||||||
|
onClose: () => void
|
||||||
|
vlans: Vlan[]
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [group, setGroup] = useState('')
|
||||||
|
const [status, setStatus] = useState<DeviceStatus>('new')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [alias, setAlias] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (device) {
|
||||||
|
setGroup(device.group ?? '')
|
||||||
|
setStatus(device.status ?? 'new')
|
||||||
|
setName(device.name ?? '')
|
||||||
|
setPhone(device.phone ?? '')
|
||||||
|
setAlias(device.alias ?? '')
|
||||||
|
}
|
||||||
|
}, [device])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!device) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.devices.edit({
|
||||||
|
mac_address: device.mac_address,
|
||||||
|
group: group || undefined,
|
||||||
|
status,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
alias,
|
||||||
|
})
|
||||||
|
toast.success(`Device ${device.mac_address} updated`)
|
||||||
|
onClose()
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to update device')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit device</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Group (VLAN)">
|
||||||
|
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as DeviceStatus)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Name">
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Phone">
|
||||||
|
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Device alias">
|
||||||
|
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Save changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Delete ----------
|
||||||
|
function DeleteDeviceDialog({
|
||||||
|
device,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
device: Device | null
|
||||||
|
onClose: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!device) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.devices.remove(device.mac_address)
|
||||||
|
toast.success(`Device ${device.mac_address} deleted`)
|
||||||
|
onClose()
|
||||||
|
onDeleted()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete device?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
|
||||||
|
radusergroup and customers. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- shared bits ----------
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupSelect({
|
||||||
|
vlans,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
vlans: Vlan[]
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a VLAN" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vlans.map((v) => (
|
||||||
|
<SelectItem key={v.vlanid} value={v.alias}>
|
||||||
|
{v.alias} (VLAN {v.vlanid})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { KeyRound, Loader2 } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/auth/auth'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!key.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(key.trim())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<KeyRound className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">RADIUS Admin</CardTitle>
|
||||||
|
<CardDescription>Enter your API key to continue</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="apikey">API key</Label>
|
||||||
|
<Input
|
||||||
|
id="apikey"
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
placeholder="X-API-Key value"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={busy || !key.trim()}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{busy ? 'Verifying…' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api, ApiError, type Vlan } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function Vlans() {
|
||||||
|
const [vlans, setVlans] = useState<Vlan[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Vlan | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<Vlan | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setVlans(await api.vlans.list())
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load VLANs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">VLANs</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{vlans.length} configured</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus /> Add VLAN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-32">VLAN ID</TableHead>
|
||||||
|
<TableHead>Alias</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && vlans.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : vlans.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||||||
|
No VLANs configured.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
vlans.map((v) => (
|
||||||
|
<TableRow key={v.vlanid}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">VLAN {v.vlanid}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{v.alias}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setEditing(v)} title="Rename">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleting(v)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddVlanDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
|
||||||
|
<RenameVlanDialog vlan={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||||
|
<DeleteVlanDialog vlan={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddVlanDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [vlanid, setVlanid] = useState('')
|
||||||
|
const [alias, setAlias] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setVlanid('')
|
||||||
|
setAlias('')
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const id = Number(vlanid)
|
||||||
|
const valid = vlanid !== '' && Number.isInteger(id) && id >= 1 && id <= 4094 && alias.trim()
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!valid) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.vlans.add(id, alias.trim())
|
||||||
|
toast.success(`VLAN ${id} (${alias.trim()}) added`)
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to add VLAN')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add VLAN</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Creates the Tunnel-Type / Medium-Type / Private-Group-Id rows for this group.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="VLAN ID (1–4094)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={4094}
|
||||||
|
placeholder="55"
|
||||||
|
value={vlanid}
|
||||||
|
onChange={(e) => setVlanid(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Alias (group name)" required>
|
||||||
|
<Input placeholder="staff" value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !valid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Add VLAN
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenameVlanDialog({
|
||||||
|
vlan,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
vlan: Vlan | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [alias, setAlias] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vlan) setAlias(vlan.alias)
|
||||||
|
}, [vlan])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!vlan || !alias.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.vlans.edit(vlan.vlanid, alias.trim())
|
||||||
|
toast.success(`VLAN ${vlan.vlanid} renamed to ${alias.trim()}`)
|
||||||
|
onClose()
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to rename VLAN')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename VLAN {vlan?.vlanid}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Updates the group name across its radgroupreply rows. Device group assignments in
|
||||||
|
radusergroup are not renamed automatically.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Field label="Alias (group name)" required>
|
||||||
|
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !alias.trim()}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteVlanDialog({
|
||||||
|
vlan,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
vlan: Vlan | null
|
||||||
|
onClose: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!vlan) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.vlans.remove(vlan.vlanid)
|
||||||
|
toast.success(`VLAN ${vlan.vlanid} deleted`)
|
||||||
|
onClose()
|
||||||
|
onDeleted()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete VLAN')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete VLAN {vlan?.vlanid}?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Removes all radgroupreply rows for <span className="font-medium">{vlan?.alias}</span>.
|
||||||
|
Devices still assigned to this group will lose their reply attributes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
// Proxy API calls in dev so the browser talks to same-origin /api,
|
||||||
|
// avoiding CORS. The API key is sent as a header, never in the URL.
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_TARGET ?? 'http://10.0.1.235:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user