added multi user support, move api key to database
build-and-push / build (push) Failing after 35s

This commit is contained in:
2026-08-01 13:16:54 +05:00
parent a7c5fe739a
commit 66073c7891
23 changed files with 2123 additions and 103 deletions
+16 -9
View File
@@ -2,8 +2,10 @@
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
(customer MACs), **devices** (NAS/AP boxes seen in accounting), 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.
without touching SQL. Admins also manage **users**, **API keys**, and view an
**activity log**. Auth is a username/password login; the backend returns a session
token stored in the browser (`localStorage`) and sent as `Authorization: Bearer`
on every request.
## Stack
@@ -29,15 +31,16 @@ 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
requests (no CORS) and the token 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.
Log in with **`admin` / `admin`** on first run (you'll be prompted to change the
password), then create real users from the **Users** page. A `401` from any request
clears the stored token and returns you to the login screen.
## Build
@@ -54,12 +57,16 @@ API (or set `VITE_API_BASE` to the API's absolute URL at build time).
```
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 (client + device + vlan) + error handling
App.tsx auth gate + forced-password-change + nav (role-gated) + routes
auth/auth.tsx session auth context (login/logout, current user, 401 handling)
lib/api.ts typed API client (auth + users + apikeys + logs + client/device/vlan)
lib/utils.ts cn() helper
pages/
Login.tsx API-key login screen
Login.tsx username/password login screen
ChangePassword.tsx change own password (also the forced first-login screen)
Users.tsx admin: list/create/delete users + reset password
ApiKeys.tsx admin: create (shown once) / list / revoke API keys
Logs.tsx admin: paginated activity log
Clients.tsx client list + add/edit/delete + CSV import/export dialogs
Devices.tsx NAS device list + editable alias
Vlans.tsx VLAN list + add/rename/delete dialogs
+50 -6
View File
@@ -1,11 +1,25 @@
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { LogOut, Moon, Router, Sun, Users, Wifi } from 'lucide-react'
import {
KeyRound,
LogOut,
Moon,
Router,
ScrollText,
Sun,
UserCog,
Users as UsersIcon,
Wifi,
} from 'lucide-react'
import { useAuth } from '@/auth/auth'
import { useTheme } from '@/lib/theme'
import { Login } from '@/pages/Login'
import { Clients } from '@/pages/Clients'
import { Devices } from '@/pages/Devices'
import { Vlans } from '@/pages/Vlans'
import { Users } from '@/pages/Users'
import { ApiKeys } from '@/pages/ApiKeys'
import { Logs } from '@/pages/Logs'
import { ChangePassword } from '@/pages/ChangePassword'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -25,7 +39,7 @@ function ThemeToggle() {
)
}
function Nav() {
function Nav({ username, isAdmin }: { username: string; isAdmin: boolean }) {
const { logout } = useAuth()
const linkClass = ({ isActive }: { isActive: boolean }) =>
cn(
@@ -41,7 +55,7 @@ function Nav() {
RADIUS Admin
</div>
<NavLink to="/clients" className={linkClass}>
<Users className="h-4 w-4" /> Clients
<UsersIcon className="h-4 w-4" /> Clients
</NavLink>
<NavLink to="/devices" className={linkClass}>
<Router className="h-4 w-4" /> Devices
@@ -49,7 +63,23 @@ function Nav() {
<NavLink to="/vlans" className={linkClass}>
<Wifi className="h-4 w-4" /> VLANs
</NavLink>
{isAdmin && (
<>
<NavLink to="/users" className={linkClass}>
<UserCog className="h-4 w-4" /> Users
</NavLink>
<NavLink to="/apikeys" className={linkClass}>
<KeyRound className="h-4 w-4" /> API Keys
</NavLink>
<NavLink to="/logs" className={linkClass}>
<ScrollText className="h-4 w-4" /> Activity Log
</NavLink>
</>
)}
<div className="ml-auto flex items-center gap-1">
<NavLink to="/account" className={linkClass} title="Change password">
{username}
</NavLink>
<ThemeToggle />
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
<LogOut className="h-4 w-4" /> Sign out
@@ -61,18 +91,32 @@ function Nav() {
}
export default function App() {
const { authenticated } = useAuth()
const { user } = useAuth()
if (!authenticated) return <Login />
if (!user) return <Login />
if (user.must_change_password) return <ChangePassword forced />
const admin = user.is_admin
return (
<div className="min-h-screen bg-muted/20">
<Nav />
<Nav username={user.username} isAdmin={admin} />
<main className="mx-auto max-w-6xl px-4 py-6">
<Routes>
<Route path="/clients" element={<Clients />} />
<Route path="/devices" element={<Devices />} />
<Route path="/vlans" element={<Vlans />} />
<Route path="/account" element={<ChangePassword />} />
<Route
path="/users"
element={admin ? <Users /> : <Navigate to="/clients" replace />}
/>
<Route
path="/apikeys"
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
/>
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
<Route path="*" element={<Navigate to="/clients" replace />} />
</Routes>
</main>
+45 -17
View File
@@ -2,49 +2,77 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
import {
api,
ApiError,
clearApiKey,
getApiKey,
setApiKey,
clearToken,
getToken,
setToken,
setUnauthorizedHandler,
type CurrentUser,
} from '@/lib/api'
interface AuthState {
user: CurrentUser | null
authenticated: boolean
login: (key: string) => Promise<void>
login: (username: string, password: string) => Promise<void>
logout: () => void
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [authenticated, setAuthenticated] = useState(() => !!getApiKey())
const [user, setUser] = useState<CurrentUser | null>(null)
const logout = useCallback(() => {
clearApiKey()
setAuthenticated(false)
api.auth.logout().catch(() => {}) // best effort; token is cleared regardless
clearToken()
setUser(null)
}, [])
// Any 401 from the API layer forces a logout so the login screen reappears.
useEffect(() => {
setUnauthorizedHandler(logout)
}, [logout])
setUnauthorizedHandler(() => {
clearToken()
setUser(null)
})
}, [])
const login = useCallback(async (key: string) => {
setApiKey(key)
// On mount, if a token exists, load the current user; drop the token if it's stale.
useEffect(() => {
if (!getToken()) return
api.auth
.me()
.then(setUser)
.catch(() => {
clearToken()
setUser(null)
})
}, [])
const login = useCallback(async (username: string, password: string) => {
try {
await api.ping() // validates the key; throws ApiError(401) if wrong
setAuthenticated(true)
const res = await api.auth.login(username, password)
setToken(res.token)
// Fetch the full profile (including id) with the fresh token.
setUser(await api.auth.me())
} catch (err) {
clearApiKey()
setAuthenticated(false)
clearToken()
setUser(null)
if (err instanceof ApiError && err.status === 401) {
throw new Error('That API key was rejected.')
throw new Error('Invalid username or password')
}
throw err instanceof Error ? err : new Error('Could not reach the API.')
}
}, [])
return <AuthContext value={{ authenticated, login, logout }}>{children}</AuthContext>
const refresh = useCallback(async () => {
setUser(await api.auth.me())
}, [])
return (
<AuthContext value={{ user, authenticated: !!user, login, logout, refresh }}>
{children}
</AuthContext>
)
}
export function useAuth(): AuthState {
+118 -14
View File
@@ -3,20 +3,23 @@
// 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'
const TOKEN_STORAGE = 'radui.token'
const OLD_KEY_STORAGE = 'radui.apiKey'
// ---- API key storage ----
export function getApiKey(): string | null {
return localStorage.getItem(KEY_STORAGE)
// ---- Session token storage ----
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE)
}
export function setApiKey(key: string) {
localStorage.setItem(KEY_STORAGE, key)
export function setToken(token: string) {
localStorage.setItem(TOKEN_STORAGE, token)
}
export function clearApiKey() {
localStorage.removeItem(KEY_STORAGE)
export function clearToken() {
localStorage.removeItem(TOKEN_STORAGE)
}
// Drop the legacy X-API-Key value left over from the old auth model.
localStorage.removeItem(OLD_KEY_STORAGE)
// A 401 anywhere means the stored key is bad — notify the app to log out.
// A 401 anywhere means the stored token is bad/expired — notify the app to log out.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn
@@ -47,16 +50,16 @@ function messageFromDetail(detail: unknown, fallback: string): string {
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const key = getApiKey()
const token = getToken()
const headers = new Headers(options.headers)
if (key) headers.set('X-API-Key', key)
if (token) headers.set('Authorization', `Bearer ${token}`)
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')
throw new ApiError(401, 'Session expired or unauthorized')
}
if (res.status === 204) return undefined as T
@@ -150,10 +153,111 @@ export interface Page<T> {
items: T[]
}
// ---- Auth / admin types ----
export interface CurrentUser {
id: number
username: string
is_admin: boolean
must_change_password: boolean
}
// A row from GET /users (admin view).
export interface AdminUser {
id: number
username: string
is_admin: boolean
must_change_password: boolean
created_at: string
}
interface LoginResponse {
token: string
username: string
is_admin: boolean
must_change_password: boolean
}
export interface ApiKeyRow {
id: number
name: string
key_prefix: string
created_by: string
created_at: string
last_used_at: string | null
revoked: boolean
}
// POST /apikeys additionally returns the raw key exactly once.
export interface ApiKeyCreated extends ApiKeyRow {
key: string
}
export interface LogRow {
id: number
username: string
action: string
detail: string | null
ip_address: string | null
created_at: string
}
export interface LogFilters {
username?: string
action?: string
}
// ---- Endpoints ----
export const api = {
// health check to validate the API key on login
ping: () => request<unknown>('/vlan/'),
auth: {
login: (username: string, password: string) =>
request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
me: () => request<CurrentUser>('/auth/me'),
// current_password is omitted on a forced first-login change (the backend
// skips the check when the account is flagged must_change_password).
changePassword: (new_password: string, current_password?: string) =>
request<void>('/auth/change-password', {
method: 'POST',
body: JSON.stringify(
current_password ? { current_password, new_password } : { new_password },
),
}),
},
users: {
list: () => request<AdminUser[]>('/users'),
create: (body: {
username: string
password: string
is_admin: boolean
force_password_change: boolean
}) => request<AdminUser>('/users', { method: 'POST', body: JSON.stringify(body) }),
remove: (id: number) => request<void>(`/users/${id}`, { method: 'DELETE' }),
resetPassword: (id: number, new_password: string, force_password_change: boolean) =>
request<void>(`/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password, force_password_change }),
}),
},
apikeys: {
list: () => request<ApiKeyRow[]>('/apikeys'),
create: (name: string) =>
request<ApiKeyCreated>('/apikeys', { method: 'POST', body: JSON.stringify({ name }) }),
revoke: (id: number) => request<void>(`/apikeys/${id}`, { method: 'DELETE' }),
},
logs: {
list: (limit = 50, offset = 0, filters: LogFilters = {}) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
if (filters.username) params.set('username', filters.username)
if (filters.action) params.set('action', filters.action)
return request<Page<LogRow>>(`/logs?${params.toString()}`)
},
},
clients: {
list: (limit = 50, offset = 0) =>
+292
View File
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useState } from 'react'
import { Ban, Check, Copy, Loader2, Plus, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { api, ApiError, type ApiKeyCreated, type ApiKeyRow } 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'
function fmt(ts: string | null) {
return ts ? new Date(ts).toLocaleString() : '—'
}
export function ApiKeys() {
const [keys, setKeys] = useState<ApiKeyRow[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [revoking, setRevoking] = useState<ApiKeyRow | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setKeys(await api.apikeys.list())
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load API keys')
} 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">API keys</h1>
<p className="text-sm text-muted-foreground">{keys.length} keys</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus /> Create API key
</Button>
</div>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Created by</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="w-24">Status</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && keys.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>
) : keys.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
No API keys yet.
</TableCell>
</TableRow>
) : (
keys.map((k) => (
<TableRow key={k.id}>
<TableCell className="font-medium">{k.name}</TableCell>
<TableCell className="font-mono text-xs">{k.key_prefix}</TableCell>
<TableCell>{k.created_by}</TableCell>
<TableCell className="text-muted-foreground">{fmt(k.created_at)}</TableCell>
<TableCell className="text-muted-foreground">{fmt(k.last_used_at)}</TableCell>
<TableCell>
{k.revoked ? (
<Badge variant="destructive">Revoked</Badge>
) : (
<Badge variant="success">Active</Badge>
)}
</TableCell>
<TableCell className="text-right">
{!k.revoked && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setRevoking(k)}
title="Revoke"
>
<Ban className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<CreateApiKeyDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={load} />
<RevokeApiKeyDialog apikey={revoking} onClose={() => setRevoking(null)} onRevoked={load} />
</div>
)
}
function CreateApiKeyDialog({
open,
onOpenChange,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
onSaved: () => void
}) {
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [created, setCreated] = useState<ApiKeyCreated | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (open) {
setName('')
setCreated(null)
setCopied(false)
}
}, [open])
async function submit() {
if (!name.trim()) return
setBusy(true)
try {
const res = await api.apikeys.create(name.trim())
setCreated(res)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create API key')
} finally {
setBusy(false)
}
}
async function copy() {
if (!created) return
try {
await navigator.clipboard.writeText(created.key)
setCopied(true)
toast.success('API key copied to clipboard')
} catch {
toast.error('Could not copy to clipboard')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
{created ? (
<>
<DialogHeader>
<DialogTitle>API key created</DialogTitle>
<DialogDescription>
Copy this key now. For security it will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>{created.name}</Label>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2 font-mono text-xs">
{created.key}
</code>
<Button variant="outline" size="icon" onClick={copy} title="Copy">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>
Generate a new API key for programmatic access.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label>
Name<span className="text-destructive"> *</span>
</Label>
<Input
placeholder="billing-sync"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
function RevokeApiKeyDialog({
apikey,
onClose,
onRevoked,
}: {
apikey: ApiKeyRow | null
onClose: () => void
onRevoked: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!apikey) return
setBusy(true)
try {
await api.apikeys.revoke(apikey.id)
toast.success(`API key ${apikey.name} revoked`)
onClose()
onRevoked()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!apikey} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revoke API key?</DialogTitle>
<DialogDescription>
<span className="font-medium">{apikey?.name}</span> will stop working immediately. 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" />}
Revoke
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { useState, type FormEvent } from 'react'
import { KeyRound, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useAuth } from '@/auth/auth'
import { api, ApiError } from '@/lib/api'
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'
const MIN_LENGTH = 6
export function ChangePassword({ forced = false }: { forced?: boolean }) {
const { refresh } = useAuth()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const tooShort = next.length > 0 && next.length < MIN_LENGTH
const mismatch = confirm.length > 0 && next !== confirm
// On a forced first-login change the current password isn't asked for — the
// user just signed in with it — so it isn't part of validation.
const valid = (forced || current.length > 0) && next.length >= MIN_LENGTH && next === confirm
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!valid) return
setBusy(true)
setError(null)
try {
await api.auth.changePassword(next, forced ? undefined : current)
await refresh()
toast.success('Password changed')
setCurrent('')
setNext('')
setConfirm('')
} catch (err) {
const msg =
err instanceof ApiError
? err.message
: err instanceof Error
? err.message
: 'Failed to change password'
setError(msg)
toast.error(msg)
} finally {
setBusy(false)
}
}
const form = (
<form onSubmit={onSubmit} className="space-y-4">
{!forced && (
<div className="space-y-2">
<Label htmlFor="current">Current password</Label>
<Input
id="current"
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="new">New password</Label>
<Input
id="new"
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
{tooShort && (
<p className="text-xs text-destructive">Must be at least {MIN_LENGTH} characters.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm new password</Label>
<Input
id="confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{mismatch && <p className="text-xs text-destructive">Passwords do not match.</p>}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy || !valid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{busy ? 'Saving…' : 'Change password'}
</Button>
</form>
)
if (forced) {
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">Change your password</CardTitle>
<CardDescription>You must change your password before continuing.</CardDescription>
</CardHeader>
<CardContent>{form}</CardContent>
</Card>
</div>
)
}
return (
<div className="mx-auto max-w-sm">
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-xl">Change password</CardTitle>
<CardDescription>Update the password for your account.</CardDescription>
</CardHeader>
<CardContent>{form}</CardContent>
</Card>
</div>
)
}
+26 -15
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { KeyRound, Loader2 } from 'lucide-react'
import { LogIn, Loader2 } from 'lucide-react'
import { useAuth } from '@/auth/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -14,17 +14,20 @@ import {
export function Login() {
const { login } = useAuth()
const [key, setKey] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const valid = username.trim() && password
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!key.trim()) return
if (!valid) return
setBusy(true)
setError(null)
try {
await login(key.trim())
await login(username.trim(), password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
@@ -37,29 +40,37 @@ export function Login() {
<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" />
<LogIn className="h-5 w-5 text-primary" />
</div>
<CardTitle className="text-xl">RADIUS Admin</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
<CardDescription>Sign in to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="apikey">API key</Label>
<Label htmlFor="username">Username</Label>
<Input
id="apikey"
type="password"
id="username"
autoFocus
placeholder="X-API-Key value"
value={key}
onChange={(e) => setKey(e.target.value)}
autoComplete="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy || !key.trim()}>
<Button type="submit" className="w-full" disabled={busy || !valid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{busy ? 'Verifying…' : 'Sign in'}
{busy ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
+126
View File
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from 'react'
import { Loader2, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import { api, ApiError, type LogRow } from '@/lib/api'
import { TablePagination, usePageSize } from '@/components/Pagination'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
function fmt(ts: string) {
return new Date(ts).toLocaleString()
}
export function Logs() {
const [items, setItems] = useState<LogRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [username, setUsername] = useState('')
const [pageSize, setPageSize] = usePageSize()
const [page, setPage] = useState(1)
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const load = useCallback(async () => {
setLoading(true)
try {
const res = await api.logs.list(pageSize, (page - 1) * pageSize, {
username: username.trim() || undefined,
})
setItems(res.items)
setTotal(res.total)
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load activity log')
} finally {
setLoading(false)
}
}, [page, pageSize, username])
useEffect(() => {
load()
}, [load])
useEffect(() => setPage(1), [pageSize, username])
useEffect(() => {
if (page > pageCount) setPage(pageCount)
}, [page, pageCount])
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-2xl font-semibold">Activity log</h1>
<p className="text-sm text-muted-foreground">{total} events</p>
</div>
<div className="flex gap-2">
<Input
placeholder="Filter by username…"
className="w-56"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Button variant="outline" size="icon" onClick={load} title="Refresh">
<RefreshCw className={loading ? 'animate-spin' : ''} />
</Button>
</div>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-48">Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Detail</TableHead>
<TableHead className="w-36">IP address</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
No activity recorded.
</TableCell>
</TableRow>
) : (
items.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground">{fmt(row.created_at)}</TableCell>
<TableCell className="font-medium">{row.username}</TableCell>
<TableCell className="font-mono text-xs">{row.action}</TableCell>
<TableCell className="text-muted-foreground">{row.detail ?? '—'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{row.ip_address ?? '—'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
page={page}
pageCount={pageCount}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</div>
)
}
+394
View File
@@ -0,0 +1,394 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { KeyRound, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { useAuth } from '@/auth/auth'
import { api, ApiError, type AdminUser } 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'
function fmt(ts: string) {
return new Date(ts).toLocaleString()
}
export function Users() {
const { user } = useAuth()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [addOpen, setAddOpen] = useState(false)
const [resetting, setResetting] = useState<AdminUser | null>(null)
const [deleting, setDeleting] = useState<AdminUser | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setUsers(await api.users.list())
} catch (err) {
if (!(err instanceof ApiError && err.status === 401))
toast.error(err instanceof Error ? err.message : 'Failed to load users')
} 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">Users</h1>
<p className="text-sm text-muted-foreground">{users.length} accounts</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 user
</Button>
</div>
</div>
<div className="rounded-lg border bg-background">
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead className="w-28">Role</TableHead>
<TableHead className="w-40">Password</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
No users.
</TableCell>
</TableRow>
) : (
users.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.username}</TableCell>
<TableCell>
<Badge variant={u.is_admin ? 'default' : 'secondary'}>
{u.is_admin ? 'Admin' : 'User'}
</Badge>
</TableCell>
<TableCell>
{u.must_change_password ? (
<Badge variant="warning">Must change</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-muted-foreground">{fmt(u.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setResetting(u)}
title="Reset password"
>
<KeyRound className="h-4 w-4" />
</Button>
{u.username !== user?.username && (
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setDeleting(u)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddUserDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
<ResetPasswordDialog user={resetting} onClose={() => setResetting(null)} onSaved={load} />
<DeleteUserDialog user={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
</div>
)
}
function AddUserDialog({
open,
onOpenChange,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
onSaved: () => void
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [forcePwChange, setForcePwChange] = useState(true)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (open) {
setUsername('')
setPassword('')
setIsAdmin(false)
setForcePwChange(true)
}
}, [open])
const valid = username.trim() && password.length >= 6
async function submit() {
if (!valid) return
setBusy(true)
try {
await api.users.create({
username: username.trim(),
password,
is_admin: isAdmin,
force_password_change: forcePwChange,
})
toast.success(`User ${username.trim()} created`)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create user')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add user</DialogTitle>
<DialogDescription>Create a new admin-portal account.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="Username" required>
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
</Field>
<Field label="Password (min 6 chars)" required>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</Field>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
/>
Administrator
</label>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
checked={forcePwChange}
onChange={(e) => setForcePwChange(e.target.checked)}
/>
Force password change on first login
</label>
</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 user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function ResetPasswordDialog({
user,
onClose,
onSaved,
}: {
user: AdminUser | null
onClose: () => void
onSaved: () => void
}) {
const [password, setPassword] = useState('')
const [forcePwChange, setForcePwChange] = useState(true)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (user) {
setPassword('')
setForcePwChange(true)
}
}, [user])
const valid = password.length >= 6
async function submit() {
if (!user || !valid) return
setBusy(true)
try {
await api.users.resetPassword(user.id, password, forcePwChange)
toast.success(`Password reset for ${user.username}`)
onClose()
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to reset password')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Reset password</DialogTitle>
<DialogDescription>
Sets a new password for <span className="font-medium">{user?.username}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="New password (min 6 chars)" required>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</Field>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
checked={forcePwChange}
onChange={(e) => setForcePwChange(e.target.checked)}
/>
Force password change on next login
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={submit} disabled={busy || !valid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
Reset password
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DeleteUserDialog({
user,
onClose,
onDeleted,
}: {
user: AdminUser | null
onClose: () => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
async function confirm() {
if (!user) return
setBusy(true)
try {
await api.users.remove(user.id)
toast.success(`User ${user.username} deleted`)
onClose()
onDeleted()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete user')
} finally {
setBusy(false)
}
}
return (
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete user?</DialogTitle>
<DialogDescription>
This permanently removes <span className="font-medium">{user?.username}</span>.
</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>
)
}