Add 'frontend/' from commit '63381d9f2a4829e09d9ba6f29608bbaa2eb38601'
git-subtree-dir: frontend git-subtree-mainline:79bc04aaa7git-subtree-split:63381d9f2a
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
FROM node:22.17.0-alpine
|
||||
|
||||
WORKDIR /var/www/html/
|
||||
|
||||
CMD npm install && npm run dev -- --host 0.0.0.0
|
||||
@@ -0,0 +1,6 @@
|
||||
VITE_API_TARGET=http://localhost:8080/
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22.17.0-alpine AS frontendbuilder
|
||||
|
||||
ARG VITE_API_TARGET
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
RUN echo VITE_API_TARGET=$VITE_API_TARGET > .env \
|
||||
&& npm ci && npm run build
|
||||
|
||||
FROM nginx
|
||||
|
||||
COPY --from=frontendbuilder /var/www/html/dist /usr/share/nginx/html
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
## Production deployment
|
||||
it is assumed that you will be using backend on the same machine, so this compose is for both services
|
||||
```yml
|
||||
services:
|
||||
frontend:
|
||||
hostname: frontend
|
||||
image: git.shihaam.dev/sarlink/radui
|
||||
ports:
|
||||
- 8000:80
|
||||
backend:
|
||||
hostname: backend
|
||||
image: git.shihaam.dev/sarlink/radapi
|
||||
ports:
|
||||
- 8001:80
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: .build/prod/Dockerfile
|
||||
args:
|
||||
VITE_API_TARGET: ${VITE_API_TARGET}
|
||||
hostname: frontend
|
||||
image: git.shihaam.dev/sarlink/radui
|
||||
@@ -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
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .build/dev
|
||||
volumes:
|
||||
- ./:/var/www/html/
|
||||
ports:
|
||||
- 5173:5173
|
||||
@@ -0,0 +1,27 @@
|
||||
<!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>
|
||||
<!-- Critical: paint the correct background before the app CSS loads (avoids flash). -->
|
||||
<style>
|
||||
html { background: oklch(1 0 0); color-scheme: light; }
|
||||
html.dark { background: oklch(0.145 0 0); color-scheme: dark; }
|
||||
</style>
|
||||
<script>
|
||||
// Apply saved (or system) theme before first paint.
|
||||
(function () {
|
||||
var t = localStorage.getItem('radui.theme')
|
||||
if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</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)"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LogOut, Moon, Router, Sun, Wifi } from 'lucide-react'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
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 ThemeToggle() {
|
||||
const { theme, toggle } = useTheme()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="text-muted-foreground"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" /> Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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,23 @@
|
||||
// Mirrors the API's phone rule (radapi schemas._normalize_phone):
|
||||
// 7 digits starting with 9 or 7. A 960/+960 country code is stripped ONLY when the
|
||||
// number is 10 digits (960 + 7) — a bare 7-digit number like 9601234 is a valid
|
||||
// local number and is never stripped.
|
||||
|
||||
export const PHONE_ERROR = 'Enter a valid phone number'
|
||||
|
||||
/** Returns the normalized 7-digit local number, or null if invalid. */
|
||||
export function normalizePhone(raw: string): string | null {
|
||||
let digits = (raw ?? '').replace(/\D/g, '')
|
||||
if (digits.length === 10 && digits.startsWith('960')) {
|
||||
digits = digits.slice(3)
|
||||
}
|
||||
if (digits.length === 7 && (digits[0] === '9' || digits[0] === '7')) {
|
||||
return digits
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Error message if invalid, else null. */
|
||||
export function phoneError(raw: string): string | null {
|
||||
return normalizePhone(raw) === null ? PHONE_ERROR : null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
const STORAGE = 'radui.theme'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeState | null>(null)
|
||||
|
||||
function initialTheme(): Theme {
|
||||
const saved = localStorage.getItem(STORAGE)
|
||||
if (saved === 'light' || saved === 'dark') return saved
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
localStorage.setItem(STORAGE, theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeState {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -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,21 @@
|
||||
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 { ThemeProvider } from './lib/theme.tsx'
|
||||
import { Toaster } from './components/ui/sonner.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,472 @@
|
||||
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 { normalizePhone, phoneError } from '@/lib/phone'
|
||||
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 phoneErr = phone.trim() ? phoneError(phone) : null
|
||||
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
|
||||
|
||||
async function submit() {
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.devices.add({
|
||||
mac_address: mac.trim(),
|
||||
group,
|
||||
name: name.trim(),
|
||||
phone: normalizePhone(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 error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
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])
|
||||
|
||||
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
|
||||
|
||||
async function submit() {
|
||||
if (!device || phoneErr) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.devices.edit({
|
||||
mac_address: device.mac_address,
|
||||
group: group || undefined,
|
||||
status,
|
||||
name,
|
||||
phone: normalizePhone(phone) ?? 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" error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
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 || !!phoneErr}>
|
||||
{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,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</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