latest
This commit is contained in:
1 parent
d0834df8df
commit
6dc35134fa
171 files changed
+19241
-49
No files matched your search
@@ -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?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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>
|
||||
Generated
+3141
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 /> },
|
||||
])
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
meta: {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface Role {
|
||||
name: string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user