Add 'frontend/' from commit '63381d9f2a4829e09d9ba6f29608bbaa2eb38601'

git-subtree-dir: frontend
git-subtree-mainline: 79bc04aaa7
git-subtree-split: 63381d9f2a
This commit is contained in:
2026-07-31 23:53:50 +05:00
39 changed files with 4968 additions and 0 deletions
+5
View File
@@ -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
+6
View File
@@ -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
+12
View File
@@ -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
+16
View File
@@ -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
```
+9
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+73
View File
@@ -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`).
+8
View File
@@ -0,0 +1,8 @@
services:
frontend:
build:
context: .build/dev
volumes:
- ./:/var/www/html/
ports:
- 5173:5173
+27
View File
@@ -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>
+2906
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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

+11
View File
@@ -0,0 +1,11 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
packages = [
pkgs.nodejs_22
];
shellHook = ''
echo "radui dev shell node $(node --version), npm $(npm --version)"
'';
}
+76
View File
@@ -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>
)
}
+54
View File
@@ -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
}
+32
View File
@@ -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 }
+48
View File
@@ -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 }
+33
View File
@@ -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 }
+92
View File
@@ -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,
}
+21
View File
@@ -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 }
+20
View File
@@ -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 }
+77
View File
@@ -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 }
+17
View File
@@ -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 }
+66
View File
@@ -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 }
+85
View File
@@ -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
View File
@@ -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' }),
},
}
+23
View File
@@ -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
}
+38
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+21
View File
@@ -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>,
)
+472
View File
@@ -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>
)
}
+69
View File
@@ -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>
)
}
+327
View File
@@ -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 (14094)" 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>
)
}
+29
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+26
View File
@@ -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/, ''),
},
},
},
})