This commit is contained in:
ghassan committed 2026-08-04 11:26:25 +03:00
1 parent d0834df8df
commit 6dc35134fa
171 files changed
+19241 -49

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# 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 }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3141
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"axios": "^1.19.0",
"mqtt": "^5.15.2",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider } from './features/auth/AuthContext'
import { ToastProvider } from './components/shared/Toast/ToastProvider'
import { ConfirmProvider } from './components/shared/ConfirmDialog'
import { router } from './routes'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ToastProvider>
<ConfirmProvider>
<RouterProvider router={router} />
</ConfirmProvider>
</ToastProvider>
</AuthProvider>
</QueryClientProvider>
)
}
export default App
@@ -0,0 +1,37 @@
import { Card } from './Card'
export interface ActivityItem {
action: string
actor_name: string
created_at: string
}
interface ActivityTimelineProps {
items: ActivityItem[]
}
export function ActivityTimeline({ items }: ActivityTimelineProps) {
return (
<Card title="Recent activity">
{items.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-500">No activity yet.</p>
) : (
<ul className="flex flex-col gap-3">
{items.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500" />
<div>
<p className="text-gray-800 dark:text-gray-200">
<span className="font-medium">{item.actor_name}</span> — {item.action.replace('.', ' ')}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
{new Date(item.created_at).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
</Card>
)
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
interface CardProps {
title?: string
children: ReactNode
}
export function Card({ title, children }: CardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
{title && <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
{children}
</div>
)
}
@@ -0,0 +1,29 @@
import { Card } from './Card'
interface ChartPanelProps {
title: string
data: Record<string, number>
}
const BAR_COLOR = 'bg-blue-500'
export function ChartPanel({ title, data }: ChartPanelProps) {
const entries = Object.entries(data)
const max = Math.max(1, ...entries.map(([, value]) => value))
return (
<Card title={title}>
<div className="flex flex-col gap-2">
{entries.map(([label, value]) => (
<div key={label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs capitalize text-gray-600 dark:text-gray-400">{label}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div className={`h-full ${BAR_COLOR}`} style={{ width: `${(value / max) * 100}%` }} />
</div>
<span className="w-6 shrink-0 text-right text-xs text-gray-500 dark:text-gray-500">{value}</span>
</div>
))}
</div>
</Card>
)
}
@@ -0,0 +1,13 @@
interface InfoBoxProps {
label: string
value: string | number
}
export function InfoBox({ label, value }: InfoBoxProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-500">{label}</p>
<p className="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</p>
</div>
)
}
@@ -0,0 +1,27 @@
import { Link } from 'react-router-dom'
import { useAuth } from '../../features/auth/AuthContext'
export function Navbar() {
const { user, logout } = useAuth()
return (
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">{user?.name}</span>
<Link
to="/settings/2fa"
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
2FA Settings
</Link>
<button
onClick={() => logout()}
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
Sign out
</button>
</div>
</header>
)
}
@@ -0,0 +1,30 @@
import { NavLink } from 'react-router-dom'
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
import { usePermission } from '../../hooks/usePermission'
export function Sidebar() {
const { can } = usePermission()
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
return (
<aside className="hidden w-56 shrink-0 border-r border-gray-200 bg-white lg:block dark:border-gray-800 dark:bg-gray-900">
<nav className="flex flex-col gap-1 p-3">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm font-medium ${
isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
</aside>
)
}
@@ -0,0 +1,28 @@
import { NavLink } from 'react-router-dom'
import { NAV_ITEMS } from '../../layouts/shell/navConfig'
import { usePermission } from '../../hooks/usePermission'
export function BottomNav() {
const { can } = usePermission()
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
return (
<nav className="flex h-14 items-stretch border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex flex-1 items-center justify-center text-sm font-medium ${
isActive
? 'text-blue-700 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useAuth } from '../../features/auth/AuthContext'
export function TopBar() {
const { logout } = useAuth()
return (
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<span className="font-semibold text-gray-900 dark:text-gray-100">webappAIO</span>
<button
onClick={() => logout()}
className="rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
>
Sign out
</button>
</header>
)
}
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { Modal } from './Modal'
interface ConfirmOptions {
title?: string
message: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
}
type ConfirmState = ConfirmOptions & { resolve: (value: boolean) => void }
type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>
const ConfirmContext = createContext<ConfirmFn | null>(null)
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ConfirmState | null>(null)
const confirm = useCallback<ConfirmFn>((options) => {
return new Promise((resolve) => setState({ ...options, resolve }))
}, [])
function handle(result: boolean) {
state?.resolve(result)
setState(null)
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Modal open={state !== null} onClose={() => handle(false)} title={state?.title ?? 'Are you sure?'}>
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">{state?.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => handle(false)}
className="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{state?.cancelLabel ?? 'Cancel'}
</button>
<button
onClick={() => handle(true)}
className={`rounded-md px-3 py-2 text-sm font-medium text-white ${
state?.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{state?.confirmLabel ?? 'Confirm'}
</button>
</div>
</Modal>
</ConfirmContext.Provider>
)
}
export function useConfirm(): ConfirmFn {
const ctx = useContext(ConfirmContext)
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider')
return ctx
}
@@ -0,0 +1,109 @@
import type { ReactNode } from 'react'
export interface DataTableColumn<T> {
key: string
label: string
sortable?: boolean
render: (row: T) => ReactNode
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[]
rows: T[]
rowKey: (row: T) => string
sort?: string
direction?: 'asc' | 'desc'
onSortChange?: (key: string) => void
page: number
lastPage: number
onPageChange: (page: number) => void
isLoading?: boolean
}
export function DataTable<T>({
columns,
rows,
rowKey,
sort,
direction,
onSortChange,
page,
lastPage,
onPageChange,
isLoading,
}: DataTableProps<T>) {
return (
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50">
<tr>
{columns.map((col) => (
<th key={col.key} className="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">
{col.sortable ? (
<button
onClick={() => onSortChange?.(col.key)}
className="flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
>
{col.label}
{sort === col.key && <span>{direction === 'asc' ? '↑' : '↓'}</span>}
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
Loading…
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-6 text-center text-gray-500">
No results.
</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
{columns.map((col) => (
<td key={col.key} className="px-4 py-2 text-gray-800 dark:text-gray-200">
{col.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{lastPage > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-sm dark:border-gray-800">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
>
Previous
</button>
<span className="text-gray-500 dark:text-gray-500">
Page {page} of {lastPage}
</span>
<button
disabled={page >= lastPage}
onClick={() => onPageChange(page + 1)}
className="rounded-md px-2 py-1 text-gray-600 hover:bg-gray-100 disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800"
>
Next
</button>
</div>
)}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { Overlay } from './Overlay'
interface DrawerProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
}
export function Drawer({ open, onClose, title, children }: DrawerProps) {
return (
<Overlay
open={open}
onClose={onClose}
position="right"
className="h-full w-full max-w-sm overflow-y-auto bg-white p-6 shadow-xl dark:bg-gray-900"
>
{title && <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
{children}
</Overlay>
)
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { Overlay } from './Overlay'
interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
}
export function Modal({ open, onClose, title, children }: ModalProps) {
return (
<Overlay
open={open}
onClose={onClose}
position="center"
className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-gray-900"
>
{title && <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
{children}
</Overlay>
)
}
@@ -0,0 +1,83 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
interface OverlayProps {
open: boolean
onClose: () => void
children: ReactNode
position: 'center' | 'right' | 'bottom'
className?: string
}
const POSITION_CLASSES: Record<OverlayProps['position'], string> = {
center: 'items-center justify-center',
right: 'items-stretch justify-end',
bottom: 'items-end justify-center',
}
/**
* Shared portal + focus-trap + ESC/backdrop-close primitive that Modal,
* Drawer, and Sheet are all thin presentational variants of.
*/
export function Overlay({ open, onClose, children, position, className = '' }: OverlayProps) {
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const previouslyFocused = document.activeElement as HTMLElement | null
panelRef.current?.focus()
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key !== 'Tab') return
const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
)
if (!focusable || focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
previouslyFocused?.focus()
}
}, [open, onClose])
if (!open) return null
return createPortal(
<div
className={`fixed inset-0 z-50 flex bg-black/40 ${POSITION_CLASSES[position]}`}
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
className={className}
>
{children}
</div>
</div>,
document.body,
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
import { Overlay } from './Overlay'
interface SheetProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
}
export function Sheet({ open, onClose, title, children }: SheetProps) {
return (
<Overlay
open={open}
onClose={onClose}
position="bottom"
className="max-h-[85vh] w-full overflow-y-auto rounded-t-2xl bg-white p-6 shadow-xl dark:bg-gray-900"
>
<div className="mx-auto mb-4 h-1.5 w-10 rounded-full bg-gray-300 dark:bg-gray-700" />
{title && <h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>}
{children}
</Overlay>
)
}
@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type ToastType = 'success' | 'error' | 'info'
interface ToastItem {
id: number
type: ToastType
message: string
}
interface ToastContextValue {
success: (message: string) => void
error: (message: string) => void
info: (message: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
const TYPE_CLASSES: Record<ToastType, string> = {
success: 'bg-green-600',
error: 'bg-red-600',
info: 'bg-gray-800',
}
let idCounter = 0
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const push = useCallback((type: ToastType, message: string) => {
const id = ++idCounter
setToasts((prev) => [...prev, { id, type, message }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000)
}, [])
const value: ToastContextValue = {
success: (message) => push('success', message),
error: (message) => push('error', message),
info: (message) => push('info', message),
}
return (
<ToastContext.Provider value={value}>
{children}
{createPortal(
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={`rounded-md px-4 py-2 text-sm text-white shadow-lg ${TYPE_CLASSES[toast.type]}`}
>
{toast.message}
</div>
))}
</div>,
document.body,
)}
</ToastContext.Provider>
)
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within ToastProvider')
return ctx
}
@@ -0,0 +1,86 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import type { User } from '../../types/user'
import * as authApi from './authApi'
import type { LoginResult } from './authApi'
import { UNAUTHORIZED_EVENT } from '../../services/apiClient'
import { connectMqtt, disconnectMqtt } from '../../services/mqttClient'
interface AuthContextValue {
user: User | null
isLoading: boolean
login: (login: string, password: string) => Promise<LoginResult>
verifyTwoFactor: (challengeToken: string, code: string) => Promise<User>
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const refresh = useCallback(async () => {
try {
const me = await authApi.fetchMe()
setUser(me)
} catch {
setUser(null)
}
}, [])
useEffect(() => {
refresh().finally(() => setIsLoading(false))
}, [refresh])
useEffect(() => {
const handleUnauthorized = () => {
setUser(null)
disconnectMqtt()
}
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized)
return () => window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized)
}, [])
// Connect (or disconnect) the MQTT client whenever auth state settles,
// covering both a fresh login and a page reload that resumes a session.
useEffect(() => {
if (user) {
connectMqtt()
} else {
disconnectMqtt()
}
}, [user])
const login = useCallback(async (loginId: string, password: string) => {
const result = await authApi.login(loginId, password)
if (!result.requiresTwoFactor) {
setUser(result.user)
}
return result
}, [])
const verifyTwoFactor = useCallback(async (challengeToken: string, code: string) => {
const verifiedUser = await authApi.verifyTwoFactor(challengeToken, code)
setUser(verifiedUser)
return verifiedUser
}, [])
const logout = useCallback(async () => {
await authApi.logout()
setUser(null)
disconnectMqtt()
}, [])
return (
<AuthContext.Provider value={{ user, isLoading, login, verifyTwoFactor, logout, refresh }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from './AuthContext'
export function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const [loginId, setLoginId] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
const result = await login(loginId, password)
if (result.requiresTwoFactor) {
navigate('/2fa-challenge', { state: { challengeToken: result.challengeToken } })
} else {
navigate('/dashboard')
}
} catch {
setError('Invalid username/email or password.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h1 className="mb-6 text-xl font-semibold text-gray-900 dark:text-gray-100">Sign in</h1>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-400">
{error}
</div>
)}
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Username or email
</label>
<input
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
required
autoFocus
className="mb-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mb-6 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,75 @@
import { useState, type FormEvent } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from './AuthContext'
interface LocationState {
challengeToken?: string
}
export function TwoFactorChallengePage() {
const { verifyTwoFactor } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const challengeToken = (location.state as LocationState | null)?.challengeToken
const [code, setCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
if (!challengeToken) {
navigate('/login', { replace: true })
return null
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await verifyTwoFactor(challengeToken!, code)
navigate('/dashboard')
} catch {
setError('That code is invalid.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<h1 className="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">Two-factor verification</h1>
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
Enter the 6-digit code from your authenticator app, or a recovery code.
</p>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-400">
{error}
</div>
)}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoFocus
className="mb-6 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Verifying…' : 'Verify'}
</button>
</form>
</div>
)
}
@@ -0,0 +1,175 @@
import { useState, type FormEvent } from 'react'
import QRCode from 'qrcode'
import { useAuth } from './AuthContext'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import * as twoFactorApi from './twoFactorApi'
type SetupStep = 'idle' | 'awaiting-code' | 'recovery-codes'
export function TwoFactorSetupPage() {
const { user, refresh } = useAuth()
const toast = useToast()
const [step, setStep] = useState<SetupStep>('idle')
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [secret, setSecret] = useState<string | null>(null)
const [code, setCode] = useState('')
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false)
async function handleEnable() {
setIsBusy(true)
setError(null)
try {
const { secret, otpauth_url } = await twoFactorApi.enableTwoFactor()
setSecret(secret)
setQrDataUrl(await QRCode.toDataURL(otpauth_url))
setStep('awaiting-code')
} catch {
setError('Could not start 2FA setup. Please try again.')
} finally {
setIsBusy(false)
}
}
async function handleConfirm(e: FormEvent) {
e.preventDefault()
setIsBusy(true)
setError(null)
try {
const codes = await twoFactorApi.confirmTwoFactor(code)
setRecoveryCodes(codes)
setStep('recovery-codes')
await refresh()
} catch {
setError('That code is invalid.')
} finally {
setIsBusy(false)
}
}
async function handleDisable(e: FormEvent) {
e.preventDefault()
setIsBusy(true)
setError(null)
try {
await twoFactorApi.disableTwoFactor(password)
setPassword('')
await refresh()
toast.success('Two-factor authentication disabled.')
} catch {
setError('That password is incorrect.')
} finally {
setIsBusy(false)
}
}
function handleDone() {
setStep('idle')
setQrDataUrl(null)
setSecret(null)
setCode('')
setRecoveryCodes([])
toast.success('Two-factor authentication enabled.')
}
return (
<div className="max-w-md">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Two-factor authentication</h1>
{error && (
<div className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-400">
{error}
</div>
)}
{user?.two_factor_enabled && step === 'idle' && (
<form onSubmit={handleDisable} className="mt-4">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Two-factor authentication is currently enabled. Enter your password to disable it.
</p>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Password"
className="mb-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="submit"
disabled={isBusy}
className="rounded-md bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
Disable 2FA
</button>
</form>
)}
{!user?.two_factor_enabled && step === 'idle' && (
<div className="mt-4">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Two-factor authentication is currently disabled.
</p>
<button
onClick={handleEnable}
disabled={isBusy}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Enable 2FA
</button>
</div>
)}
{step === 'awaiting-code' && (
<form onSubmit={handleConfirm} className="mt-4">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Scan this QR code with your authenticator app, then enter the 6-digit code it shows.
</p>
{qrDataUrl && <img src={qrDataUrl} alt="2FA QR code" className="mb-4 h-48 w-48" />}
{secret && (
<p className="mb-4 break-all font-mono text-xs text-gray-500 dark:text-gray-500">Secret: {secret}</p>
)}
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
required
autoFocus
placeholder="123456"
className="mb-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="submit"
disabled={isBusy}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Confirm
</button>
</form>
)}
{step === 'recovery-codes' && (
<div className="mt-4">
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Save these recovery codes somewhere safe. Each can be used once if you lose access to your
authenticator app. They will not be shown again.
</p>
<ul className="mb-4 grid grid-cols-2 gap-2 rounded-md bg-gray-100 p-4 font-mono text-sm dark:bg-gray-800">
{recoveryCodes.map((rc) => (
<li key={rc}>{rc}</li>
))}
</ul>
<button
onClick={handleDone}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Done
</button>
</div>
)}
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { apiClient, ensureCsrfCookie } from '../../services/apiClient'
import type { User } from '../../types/user'
export type LoginResult =
| { requiresTwoFactor: true; challengeToken: string }
| { requiresTwoFactor: false; user: User }
export async function login(loginId: string, password: string): Promise<LoginResult> {
await ensureCsrfCookie()
const { data } = await apiClient.post('/auth/login', { login: loginId, password })
if (data.requires_2fa) {
return { requiresTwoFactor: true, challengeToken: data.challenge_token }
}
return { requiresTwoFactor: false, user: data.data as User }
}
export async function verifyTwoFactor(challengeToken: string, code: string): Promise<User> {
const { data } = await apiClient.post('/auth/2fa/verify', { challenge_token: challengeToken, code })
return data.data as User
}
export async function logout(): Promise<void> {
await apiClient.post('/auth/logout')
}
export async function fetchMe(): Promise<User> {
const { data } = await apiClient.get('/auth/me')
return data.data as User
}
@@ -0,0 +1,20 @@
import { apiClient } from '../../services/apiClient'
export interface EnableTwoFactorResponse {
secret: string
otpauth_url: string
}
export async function enableTwoFactor(): Promise<EnableTwoFactorResponse> {
const { data } = await apiClient.post('/auth/2fa/enable')
return data
}
export async function confirmTwoFactor(code: string): Promise<string[]> {
const { data } = await apiClient.post('/auth/2fa/confirm', { code })
return data.recovery_codes as string[]
}
export async function disableTwoFactor(password: string): Promise<void> {
await apiClient.post('/auth/2fa/disable', { password })
}
@@ -0,0 +1,43 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext'
import { usePermission } from '../../hooks/usePermission'
import { useMqttTopic } from '../../hooks/useMqtt'
import { InfoBox } from '../../components/desktop/InfoBox'
import { ChartPanel } from '../../components/desktop/ChartPanel'
import { ActivityTimeline } from '../../components/desktop/ActivityTimeline'
import { fetchStats, fetchActivity } from './dashboardApi'
export function DashboardPage() {
const { user } = useAuth()
const { can } = usePermission()
const queryClient = useQueryClient()
const { data: stats } = useQuery({ queryKey: ['dashboard', 'stats'], queryFn: fetchStats })
const { data: activity } = useQuery({
queryKey: ['dashboard', 'activity'],
queryFn: fetchActivity,
enabled: can('audit.view'),
})
useMqttTopic('module/users/events', () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
})
return (
<div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Dashboard</h1>
<p className="mt-1 mb-6 text-sm text-gray-600 dark:text-gray-400">Welcome, {user?.name}.</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<InfoBox label="Total users" value={stats?.total_users ?? '—'} />
{stats && <ChartPanel title="Users by role" data={stats.users_by_role} />}
</div>
{can('audit.view') && (
<div className="mt-4">
<ActivityTimeline items={activity ?? []} />
</div>
)}
</div>
)
}
@@ -0,0 +1,17 @@
import { apiClient } from '../../services/apiClient'
import type { ActivityItem } from '../../components/desktop/ActivityTimeline'
export interface DashboardStats {
total_users: number
users_by_role: Record<string, number>
}
export async function fetchStats(): Promise<DashboardStats> {
const { data } = await apiClient.get('/dashboard/stats')
return data
}
export async function fetchActivity(): Promise<ActivityItem[]> {
const { data } = await apiClient.get('/dashboard/activity')
return data.data as ActivityItem[]
}
@@ -0,0 +1,53 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Modal } from '../../components/shared/Modal'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { fetchRoles } from './rolesApi'
import * as usersApi from './usersApi'
import type { User } from '../../types/user'
interface AssignRoleModalProps {
open: boolean
onClose: () => void
user: User
}
export function AssignRoleModal({ open, onClose, user }: AssignRoleModalProps) {
const queryClient = useQueryClient()
const toast = useToast()
const [role, setRole] = useState(user.roles[0] ?? 'user')
const { data: roles = [] } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
const mutation = useMutation({
mutationFn: () => usersApi.assignRole(user.id, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(`${user.name}'s role was changed to ${role}.`)
onClose()
},
})
return (
<Modal open={open} onClose={onClose} title={`Change role — ${user.name}`}>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="mb-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
>
{roles.map((r) => (
<option key={r.name} value={r.name}>
{r.name}
</option>
))}
</select>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
</Modal>
)
}
@@ -0,0 +1,79 @@
import type { Role } from '../../types/role'
export interface UserFormValues {
name: string
username: string
email: string
password: string
role: string
}
interface UserFormFieldsProps {
values: UserFormValues
onChange: (values: UserFormValues) => void
roles: Role[]
mode: 'create' | 'edit'
}
const inputClass =
'mb-4 w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100'
const labelClass = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300'
export function UserFormFields({ values, onChange, roles, mode }: UserFormFieldsProps) {
return (
<>
<label className={labelClass}>Name</label>
<input
type="text"
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
required
className={inputClass}
/>
<label className={labelClass}>Username</label>
<input
type="text"
value={values.username}
onChange={(e) => onChange({ ...values, username: e.target.value })}
required
className={inputClass}
/>
<label className={labelClass}>Email</label>
<input
type="email"
value={values.email}
onChange={(e) => onChange({ ...values, email: e.target.value })}
required
className={inputClass}
/>
<label className={labelClass}>{mode === 'create' ? 'Password' : 'New password (optional)'}</label>
<input
type="password"
value={values.password}
onChange={(e) => onChange({ ...values, password: e.target.value })}
required={mode === 'create'}
className={inputClass}
/>
{mode === 'create' && (
<>
<label className={labelClass}>Role</label>
<select
value={values.role}
onChange={(e) => onChange({ ...values, role: e.target.value })}
className={inputClass}
>
{roles.map((role) => (
<option key={role.name} value={role.name}>
{role.name}
</option>
))}
</select>
</>
)}
</>
)
}
@@ -0,0 +1,89 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Modal } from '../../components/shared/Modal'
import { Sheet } from '../../components/shared/Sheet'
import { useIsMobile } from '../../layouts/shell/useIsMobile'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { UserFormFields, type UserFormValues } from './UserFormFields'
import { fetchRoles } from './rolesApi'
import * as usersApi from './usersApi'
import type { User } from '../../types/user'
interface UserFormModalProps {
open: boolean
onClose: () => void
user?: User
}
const EMPTY_VALUES: UserFormValues = { name: '', username: '', email: '', password: '', role: 'user' }
export function UserFormModal({ open, onClose, user }: UserFormModalProps) {
const isMobile = useIsMobile()
const queryClient = useQueryClient()
const toast = useToast()
const mode = user ? 'edit' : 'create'
const [values, setValues] = useState<UserFormValues>(
user ? { ...EMPTY_VALUES, name: user.name, username: user.username, email: user.email } : EMPTY_VALUES,
)
const [error, setError] = useState<string | null>(null)
const { data: roles = [] } = useQuery({ queryKey: ['roles'], queryFn: fetchRoles, enabled: mode === 'create' })
const mutation = useMutation({
mutationFn: async () => {
if (mode === 'create') {
return usersApi.createUser(values)
}
const payload: usersApi.UpdateUserPayload = {
name: values.name,
username: values.username,
email: values.email,
}
if (values.password) payload.password = values.password
return usersApi.updateUser(user!.id, payload)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(mode === 'create' ? 'User created.' : 'User updated.')
onClose()
},
onError: () => setError('Could not save this user. Check the fields and try again.'),
})
function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
mutation.mutate()
}
const content = (
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-400">
{error}
</div>
)}
<UserFormFields values={values} onChange={setValues} roles={roles} mode={mode} />
<button
type="submit"
disabled={mutation.isPending}
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
</form>
)
const title = mode === 'create' ? 'Add user' : 'Edit user'
return isMobile ? (
<Sheet open={open} onClose={onClose} title={title}>
{content}
</Sheet>
) : (
<Modal open={open} onClose={onClose} title={title}>
{content}
</Modal>
)
}
@@ -0,0 +1,118 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission'
import { fetchUsers, deleteUser } from './usersApi'
import { useUsersRealtime } from './useUsersRealtime'
import { UserFormModal } from './UserFormModal'
import { AssignRoleModal } from './AssignRoleModal'
import type { User } from '../../types/user'
export function UsersListPageMobile() {
const { can } = usePermission()
const confirm = useConfirm()
const toast = useToast()
const queryClient = useQueryClient()
useUsersRealtime()
const [page, setPage] = useState(1)
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort: 'created_at', direction: 'desc' as const }],
queryFn: () => fetchUsers({ page, sort: 'created_at', direction: 'desc' }),
})
async function handleDelete(user: User) {
const ok = await confirm({
title: 'Delete user',
message: `Delete ${user.name}? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
})
if (!ok) return
await deleteUser(user.id)
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(`${user.name} was deleted.`)
}
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
{can('users.create') && (
<button
onClick={() => setFormUser(null)}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add
</button>
)}
</div>
{isLoading ? (
<p className="text-sm text-gray-500">Loading…</p>
) : (
<div className="flex flex-col gap-3">
{(data?.data ?? []).map((u) => (
<div
key={u.id}
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<p className="font-medium text-gray-900 dark:text-gray-100">{u.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-500">{u.email}</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-600">{u.roles.join(', ')}</p>
<div className="mt-3 flex gap-4 text-sm">
{u.can_edit && (
<button onClick={() => setFormUser(u)} className="text-blue-600 dark:text-blue-400">
Edit
</button>
)}
{can('roles.assign') && (
<button onClick={() => setRoleUser(u)} className="text-blue-600 dark:text-blue-400">
Role
</button>
)}
{u.can_delete && (
<button onClick={() => handleDelete(u)} className="text-red-600 dark:text-red-400">
Delete
</button>
)}
</div>
</div>
))}
</div>
)}
{data && data.meta.last_page > 1 && (
<div className="mt-4 flex items-center justify-between text-sm">
<button
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
>
Previous
</button>
<span className="text-gray-500">
Page {page} of {data.meta.last_page}
</span>
<button
disabled={page >= data.meta.last_page}
onClick={() => setPage((p) => p + 1)}
className="text-gray-600 disabled:opacity-40 dark:text-gray-400"
>
Next
</button>
</div>
)}
{formUser !== undefined && (
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
)}
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
</div>
)
}
@@ -0,0 +1,117 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { DataTable, type DataTableColumn } from '../../components/shared/DataTable'
import { useConfirm } from '../../components/shared/ConfirmDialog'
import { useToast } from '../../components/shared/Toast/ToastProvider'
import { usePermission } from '../../hooks/usePermission'
import { fetchUsers, deleteUser } from './usersApi'
import { useUsersRealtime } from './useUsersRealtime'
import { UserFormModal } from './UserFormModal'
import { AssignRoleModal } from './AssignRoleModal'
import type { User } from '../../types/user'
export function UsersListPage() {
const { can } = usePermission()
const confirm = useConfirm()
const toast = useToast()
const queryClient = useQueryClient()
useUsersRealtime()
const [page, setPage] = useState(1)
const [sort, setSort] = useState('created_at')
const [direction, setDirection] = useState<'asc' | 'desc'>('desc')
const [formUser, setFormUser] = useState<User | null | undefined>(undefined)
const [roleUser, setRoleUser] = useState<User | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['users', { page, sort, direction }],
queryFn: () => fetchUsers({ page, sort, direction }),
})
function handleSort(key: string) {
if (sort === key) {
setDirection(direction === 'asc' ? 'desc' : 'asc')
} else {
setSort(key)
setDirection('asc')
}
}
async function handleDelete(user: User) {
const ok = await confirm({
title: 'Delete user',
message: `Delete ${user.name}? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
})
if (!ok) return
await deleteUser(user.id)
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success(`${user.name} was deleted.`)
}
const columns: DataTableColumn<User>[] = [
{ key: 'name', label: 'Name', sortable: true, render: (u) => u.name },
{ key: 'username', label: 'Username', sortable: true, render: (u) => u.username },
{ key: 'email', label: 'Email', sortable: true, render: (u) => u.email },
{ key: 'roles', label: 'Role', render: (u) => u.roles.join(', ') },
{
key: 'actions',
label: '',
render: (u) => (
<div className="flex gap-2">
{u.can_edit && (
<button onClick={() => setFormUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
Edit
</button>
)}
{can('roles.assign') && (
<button onClick={() => setRoleUser(u)} className="text-blue-600 hover:underline dark:text-blue-400">
Role
</button>
)}
{u.can_delete && (
<button onClick={() => handleDelete(u)} className="text-red-600 hover:underline dark:text-red-400">
Delete
</button>
)}
</div>
),
},
]
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Users</h1>
{can('users.create') && (
<button
onClick={() => setFormUser(null)}
className="rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add user
</button>
)}
</div>
<DataTable
columns={columns}
rows={data?.data ?? []}
rowKey={(u) => u.id}
sort={sort}
direction={direction}
onSortChange={handleSort}
page={page}
lastPage={data?.meta.last_page ?? 1}
onPageChange={setPage}
isLoading={isLoading}
/>
{formUser !== undefined && (
<UserFormModal open onClose={() => setFormUser(undefined)} user={formUser ?? undefined} />
)}
{roleUser && <AssignRoleModal open onClose={() => setRoleUser(null)} user={roleUser} />}
</div>
)
}
@@ -0,0 +1,8 @@
import { useIsMobile } from '../../layouts/shell/useIsMobile'
import { UsersListPage } from './UsersListPage'
import { UsersListPageMobile } from './UsersListPage.mobile'
export function UsersPage() {
const isMobile = useIsMobile()
return isMobile ? <UsersListPageMobile /> : <UsersListPage />
}
+7
View File
@@ -0,0 +1,7 @@
import { apiClient } from '../../services/apiClient'
import type { Role } from '../../types/role'
export async function fetchRoles(): Promise<Role[]> {
const { data } = await apiClient.get('/roles')
return data.data as Role[]
}
@@ -0,0 +1,28 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMqttTopic } from '../../hooks/useMqtt'
import { useToast } from '../../components/shared/Toast/ToastProvider'
interface UsersEventPayload {
type: 'UserCreated' | 'UserUpdated' | 'UserDeleted' | 'RoleAssigned'
user: { name: string }
}
const MESSAGES: Record<UsersEventPayload['type'], (name: string) => string> = {
UserCreated: (name) => `${name} was added.`,
UserUpdated: (name) => `${name} was updated.`,
UserDeleted: (name) => `${name} was removed.`,
RoleAssigned: (name) => `${name}'s role was changed.`,
}
/** Keeps the users list live: any create/update/delete/role-assign anywhere
* refreshes every open Users list with zero manual refresh. */
export function useUsersRealtime() {
const queryClient = useQueryClient()
const toast = useToast()
useMqttTopic('module/users/events', (payload) => {
const event = payload as UsersEventPayload
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.info(MESSAGES[event.type]?.(event.user.name) ?? 'Users list updated.')
})
}
+49
View File
@@ -0,0 +1,49 @@
import { apiClient } from '../../services/apiClient'
import type { User } from '../../types/user'
import type { PaginatedResponse } from '../../types/pagination'
export interface UsersQuery {
search?: string
sort?: string
direction?: 'asc' | 'desc'
page?: number
}
export async function fetchUsers(query: UsersQuery): Promise<PaginatedResponse<User>> {
const { data } = await apiClient.get('/users', { params: query })
return data
}
export interface CreateUserPayload {
name: string
username: string
email: string
password: string
role: string
}
export async function createUser(payload: CreateUserPayload): Promise<User> {
const { data } = await apiClient.post('/users', payload)
return data.data as User
}
export interface UpdateUserPayload {
name?: string
username?: string
email?: string
password?: string
}
export async function updateUser(id: string, payload: UpdateUserPayload): Promise<User> {
const { data } = await apiClient.patch(`/users/${id}`, payload)
return data.data as User
}
export async function deleteUser(id: string): Promise<void> {
await apiClient.delete(`/users/${id}`)
}
export async function assignRole(id: string, role: string): Promise<User> {
const { data } = await apiClient.patch(`/users/${id}/role`, { role })
return data.data as User
}
+9
View File
@@ -0,0 +1,9 @@
import { useEffect } from 'react'
import { subscribeTopic } from '../services/mqttClient'
export function useMqttTopic(topic: string, handler: (payload: unknown) => void) {
useEffect(() => {
return subscribeTopic(topic, handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topic])
}
+15
View File
@@ -0,0 +1,15 @@
import { useAuth } from '../features/auth/AuthContext'
/**
* Presentational only — hides nav items/buttons the user can't act on.
* The API is the real permission gate; this never substitutes for it.
*/
export function usePermission() {
const { user } = useAuth()
function can(permission: string): boolean {
return user?.permissions.includes(permission) ?? false
}
return { can }
}
+9
View File
@@ -0,0 +1,9 @@
@import "tailwindcss";
:root {
color-scheme: light dark;
}
body {
margin: 0;
}
+17
View File
@@ -0,0 +1,17 @@
import { useIsMobile } from './useIsMobile'
import { MobileShell } from './MobileShell'
import { DesktopShell } from './DesktopShell'
import { useMqttTopic } from '../../hooks/useMqtt'
import { useToast } from '../../components/shared/Toast/ToastProvider'
export function AppShell() {
const isMobile = useIsMobile()
const toast = useToast()
useMqttTopic('broadcast/system', (payload) => {
const message = (payload as { message?: string })?.message ?? 'System announcement received.'
toast.info(message)
})
return isMobile ? <MobileShell /> : <DesktopShell />
}
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom'
import { Navbar } from '../../components/desktop/Navbar'
import { Sidebar } from '../../components/desktop/Sidebar'
export function DesktopShell() {
return (
<div className="flex h-screen flex-col">
<Navbar />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 dark:bg-gray-950">
<Outlet />
</main>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import { Outlet } from 'react-router-dom'
import { TopBar } from '../../components/mobile/TopBar'
import { BottomNav } from '../../components/mobile/BottomNav'
export function MobileShell() {
return (
<div className="flex h-screen flex-col">
<TopBar />
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 dark:bg-gray-950">
<Outlet />
</main>
<BottomNav />
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export interface NavItem {
label: string
to: string
permission?: string
}
export const NAV_ITEMS: NavItem[] = [
{ label: 'Dashboard', to: '/dashboard', permission: 'dashboard.view' },
{ label: 'Users', to: '/users', permission: 'users.view' },
]
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'
const MOBILE_BREAKPOINT = '(max-width: 1023px)'
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_BREAKPOINT).matches)
useEffect(() => {
const mql = window.matchMedia(MOBILE_BREAKPOINT)
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [])
return isMobile
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+16
View File
@@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../features/auth/AuthContext'
export function ProtectedRoute() {
const { user, isLoading } = useAuth()
if (isLoading) {
return <div className="flex min-h-screen items-center justify-center text-gray-500">Loading…</div>
}
if (!user) {
return <Navigate to="/login" replace />
}
return <Outlet />
}
+16
View File
@@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom'
import { usePermission } from '../hooks/usePermission'
interface RequirePermissionProps {
permission: string
}
export function RequirePermission({ permission }: RequirePermissionProps) {
const { can } = usePermission()
if (!can(permission)) {
return <Navigate to="/dashboard" replace />
}
return <Outlet />
}
+31
View File
@@ -0,0 +1,31 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { LoginPage } from '../features/auth/LoginPage'
import { TwoFactorChallengePage } from '../features/auth/TwoFactorChallengePage'
import { TwoFactorSetupPage } from '../features/auth/TwoFactorSetupPage'
import { DashboardPage } from '../features/dashboard/DashboardPage'
import { UsersPage } from '../features/users/UsersPage'
import { AppShell } from '../layouts/shell/AppShell'
import { ProtectedRoute } from './ProtectedRoute'
import { RequirePermission } from './RequirePermission'
export const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/2fa-challenge', element: <TwoFactorChallengePage /> },
{
element: <ProtectedRoute />,
children: [
{
element: <AppShell />,
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings/2fa', element: <TwoFactorSetupPage /> },
{
element: <RequirePermission permission="users.view" />,
children: [{ path: '/users', element: <UsersPage /> }],
},
],
},
],
},
{ path: '/', element: <Navigate to="/dashboard" replace /> },
])
+24
View File
@@ -0,0 +1,24 @@
import axios from 'axios'
export const apiClient = axios.create({
baseURL: '/api/v1',
withCredentials: true,
withXSRFToken: true,
headers: { Accept: 'application/json' },
})
export async function ensureCsrfCookie() {
await axios.get('/sanctum/csrf-cookie', { baseURL: '/', withCredentials: true, withXSRFToken: true })
}
export const UNAUTHORIZED_EVENT = 'auth:unauthorized'
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
return Promise.reject(error)
},
)
+81
View File
@@ -0,0 +1,81 @@
import mqtt, { type MqttClient } from 'mqtt'
import { apiClient } from './apiClient'
type MessageHandler = (payload: unknown) => void
let client: MqttClient | null = null
const subscriptions = new Map<string, Set<MessageHandler>>()
async function fetchBrokerCredentials() {
const { data } = await apiClient.get('/mqtt/token')
return data as { ws_url: string; username: string; password: string }
}
export async function connectMqtt() {
if (client) return
const { ws_url, username, password } = await fetchBrokerCredentials()
client = mqtt.connect(ws_url, {
username,
password,
reconnectPeriod: 2000,
connectTimeout: 8000,
})
client.on('connect', () => {
for (const topic of subscriptions.keys()) {
client?.subscribe(topic, { qos: 1 })
}
})
// The broker credential is a short-lived token; refresh it before mqtt.js's
// built-in reconnect fires, since a stale token would just fail again.
client.on('close', () => {
if (!client) return
fetchBrokerCredentials()
.then(({ username, password }) => {
if (!client) return
client.options.username = username
client.options.password = password
})
.catch(() => {
// Reconnect will simply retry with the existing (possibly stale)
// credentials; the next 'close' gives another chance to refresh.
})
})
client.on('message', (topic, message) => {
let payload: unknown
try {
payload = JSON.parse(message.toString())
} catch {
payload = message.toString()
}
subscriptions.get(topic)?.forEach((handler) => handler(payload))
})
}
export function disconnectMqtt() {
client?.end(true)
client = null
subscriptions.clear()
}
export function subscribeTopic(topic: string, handler: MessageHandler): () => void {
if (!subscriptions.has(topic)) {
subscriptions.set(topic, new Set())
client?.subscribe(topic, { qos: 1 })
}
subscriptions.get(topic)!.add(handler)
return () => {
const handlers = subscriptions.get(topic)
if (!handlers) return
handlers.delete(handler)
if (handlers.size === 0) {
subscriptions.delete(topic)
client?.unsubscribe(topic)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
last_page: number
per_page: number
total: number
}
}
+3
View File
@@ -0,0 +1,3 @@
export interface Role {
name: string
}
+12
View File
@@ -0,0 +1,12 @@
export interface User {
id: string
name: string
username: string
email: string
two_factor_enabled: boolean
roles: string[]
permissions: string[]
created_at: string
can_edit: boolean
can_delete: boolean
}
+26
View File
@@ -0,0 +1,26 @@
{
"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",
"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"]
}
+20
View File
@@ -0,0 +1,20 @@
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()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/sanctum': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})