diff --git a/README.md b/README.md index 66e95e3..e247126 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RouteForge Logo

- Version + Version License Status Selfhosted @@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external ## Current Alpha Status -RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.4-beta**. +RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.6.5-beta**. ## Quickstart with Docker Compose @@ -178,7 +178,9 @@ RouteForge is read-only by design: - RIPEstat payloads can vary over time. - No local RPKI validator yet. - No full BGP monitoring replacement. -- No user management yet. +- No OAuth/SSO yet. +- No LDAP yet. +- No email password reset flow yet. ## Selfhosting @@ -262,3 +264,13 @@ For production polish and selfhosting hardening guidance, see: - `docs/operations/release-checklist.md` In the standard Docker setup, API calls are same-origin via frontend nginx (`/api` proxy). CORS is primarily needed for split frontend/backend deployments. + + +## User Management + +- Initial admin setup is required on first start. +- Login/Logout are session-cookie based. +- Roles: `admin`, `operator`, `viewer`. +- User management is **admin-only**. +- Viewers cannot execute checks. +- Keep `SECRET_KEY` stable; changing it invalidates existing sessions. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6957f78..7bbc10b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,29 @@ # Release Notes +## v0.6.5-beta + +**Auth UX & Admin Console Polish** + +### Highlights + +- Logged-in user and role are clearly visible in the UI. +- Added visible logout flow. +- Added admin-only user management UI. +- Added role-aware navigation for admin/operator/viewer. +- Improved permission and session-expired messages. +- Dashboard now explains current user capabilities. +- User management API responses avoid password hash exposure. +- Audit Log UI/API may be included if implemented. + +### Known limitations + +- No OAuth/SSO yet. +- No LDAP yet. +- No email password reset flow yet. +- Audit log UI may still be limited if not implemented in this sprint. + +--- + ## v0.6.4-beta **Alembic Logging Config Hotfix** diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py index a6f8eb5..e397853 100644 --- a/backend/app/api/routes_system.py +++ b/backend/app/api/routes_system.py @@ -12,7 +12,7 @@ def system_info(): return { 'name': 'RouteForge', - 'version': 'v0.6.4-beta', + 'version': 'v0.6.5-beta', 'demo_mode': settings.demo_mode, 'read_only': True, 'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'], diff --git a/backend/app/api/routes_users.py b/backend/app/api/routes_users.py index 0d7aba5..3ef1927 100644 --- a/backend/app/api/routes_users.py +++ b/backend/app/api/routes_users.py @@ -24,7 +24,7 @@ class UserPatch(BaseModel): @router.get('') def list_users(_: User = Depends(require_admin), db: Session = Depends(get_db)): users = db.query(User).order_by(User.id.asc()).all() - return [{"id":u.id,"username":u.username,"email":u.email,"role":u.role,"is_active":u.is_active,"created_at":u.created_at.isoformat()} for u in users] + return [{"id":u.id,"username":u.username,"email":u.email,"role":u.role,"is_active":u.is_active,"created_at":u.created_at.isoformat(),"updated_at":u.updated_at.isoformat(),"last_login_at":u.last_login_at.isoformat() if u.last_login_at else None} for u in users] @router.post('') def create_user(payload: UserCreate, _: User = Depends(require_admin), db: Session = Depends(get_db)): @@ -34,18 +34,21 @@ def create_user(payload: UserCreate, _: User = Depends(require_admin), db: Sessi if errs: raise HTTPException(status_code=400, detail='; '.join(errs)) user=User(username=payload.username.strip(), email=payload.email, password_hash=hash_password(payload.password), role=payload.role, is_active=True) db.add(user); db.commit(); db.refresh(user) - return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active} + return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None} @router.patch('/{user_id}') def patch_user(user_id:int,payload:UserPatch,_:User=Depends(require_admin),db:Session=Depends(get_db)): user=db.query(User).filter(User.id==user_id).first() if not user: raise HTTPException(status_code=404, detail='User not found') if payload.email is not None: user.email=payload.email - if payload.role is not None: user.role=payload.role + if payload.role is not None: + if payload.role not in {'admin','operator','viewer'}: + raise HTTPException(status_code=400, detail='Invalid role') + user.role=payload.role if payload.is_active is not None: user.is_active=payload.is_active if payload.password is not None: errs=validate_password_strength(payload.password) if errs: raise HTTPException(status_code=400, detail='; '.join(errs)) user.password_hash=hash_password(payload.password) db.commit(); db.refresh(user) - return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active} + return {"id":user.id,"username":user.username,"email":user.email,"role":user.role,"is_active":user.is_active,"created_at":user.created_at.isoformat(),"updated_at":user.updated_at.isoformat(),"last_login_at":user.last_login_at.isoformat() if user.last_login_at else None} diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index e885b7c..3ab8f7c 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict: return { "status": "ok", "name": settings.app_name, - "version": "v0.6.4-beta", + "version": "v0.6.5-beta", "read_only": True, "mode": "demo" if settings.demo_mode else "live", "demo_mode": settings.demo_mode, diff --git a/backend/app/main.py b/backend/app/main.py index ac2a577..39e7a72 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,7 +17,7 @@ logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logger = logging.getLogger("routeforge") -app = FastAPI(title="RouteForge", version="0.6.4") +app = FastAPI(title="RouteForge", version="0.6.5") app.add_middleware( CORSMiddleware, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d54f240..f9a8e1f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routeforge-backend" -version = "0.6.4" +version = "0.6.5" description = "RouteForge backend" license = "AGPL-3.0-or-later" requires-python = ">=3.12" diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index e1876a3..b3f13be 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None: response = client.get('/api/system/status') assert response.status_code == 200 payload = response.json() - assert payload.get('version') == 'v0.6.4-beta' + assert payload.get('version') == 'v0.6.5-beta' assert payload.get('read_only') is True assert payload.get('database', {}).get('status') assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None @@ -278,3 +278,45 @@ def rollback(self): except Exception as exc: assert getattr(exc, "status_code", None) == 503 assert "Database schema is not up to date" in str(getattr(exc, "detail", "")) + +def test_users_endpoint_admin_only_and_no_password_hash() -> None: + client = _client() + _setup_and_login(client) + created = client.post('/api/users', json={'username': 'u1', 'email': 'u1@example.org', 'password': 'UserPass123!', 'role': 'viewer'}) + assert created.status_code == 200 + assert 'password_hash' not in created.json() + resp = client.get('/api/users') + assert resp.status_code == 200 + for row in resp.json(): + assert 'password_hash' not in row + assert 'updated_at' in row + assert 'last_login_at' in row + + +def test_users_endpoint_forbidden_for_operator_and_viewer() -> None: + client = _client() + _setup_and_login(client) + assert client.post('/api/users', json={'username': 'op2', 'email': 'op2@example.org', 'password': 'OperatorPass123!', 'role': 'operator'}).status_code == 200 + assert client.post('/api/users', json={'username': 'vw2', 'email': 'vw2@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'}).status_code == 200 + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'op2', 'password': 'OperatorPass123!'}).status_code == 200 + assert client.get('/api/users').status_code == 403 + + client.post('/api/auth/logout') + assert client.post('/api/auth/login', json={'username': 'vw2', 'password': 'ViewerPass123!'}).status_code == 200 + assert client.get('/api/users').status_code == 403 + + +def test_inactive_user_cannot_login() -> None: + client = _client() + _setup_and_login(client) + create = client.post('/api/users', json={'username': 'inactive1', 'email': 'inactive@example.org', 'password': 'InactivePass123!', 'role': 'viewer'}) + assert create.status_code == 200 + uid = create.json()['id'] + patch = client.patch(f'/api/users/{uid}', json={'is_active': False}) + assert patch.status_code == 200 + + client.post('/api/auth/logout') + login = client.post('/api/auth/login', json={'username': 'inactive1', 'password': 'InactivePass123!'}) + assert login.status_code == 401 diff --git a/docs/operations/security.md b/docs/operations/security.md index 8a8bc8b..ad25db4 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -55,5 +55,8 @@ The backend entrypoint ensures `/app/data` is writable for the non-root runtime ## Current limitations -- no authentication yet -- no multi-user support yet +- Role model: admin/operator/viewer +- Admin-only user management +- Inactive users cannot log in +- Password reset is admin-driven (set new password in user management) +- No external auth/SSO in this version diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md index 6cd3136..b993ad3 100644 --- a/docs/operations/troubleshooting.md +++ b/docs/operations/troubleshooting.md @@ -33,3 +33,20 @@ Update auf `v0.6.4-beta` oder neuer, dann: ```bash docker compose exec backend alembic upgrade head ``` + + +## Ich sehe keine Check-Menüpunkte + +Rolle prüfen: `viewer` sieht nur Dashboard/Reports/About. + +## 403 bei Checks + +User ist `viewer` oder inaktiv. Rolle und `is_active` im Admin User Management prüfen. + +## Login geht nicht + +Prüfen: User aktiv? Passwort korrekt? Wurde `SECRET_KEY` geändert? + +## Nach SECRET_KEY Änderung + +Alle Sessions sind ungültig. Bitte neu einloggen. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c44e3eb..1e71bec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "routeforge-frontend", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "routeforge-frontend", - "version": "0.6.4", + "version": "0.6.5", "license": "AGPL-3.0-or-later", "dependencies": { "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index dbfd135..324d1af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "routeforge-frontend", - "version": "0.6.4", + "version": "0.6.5", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cdcc2d..34b159a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,9 +7,10 @@ import { PrefixCheckForm } from './components/PrefixCheckForm' import { PreflightCheckForm } from './components/PreflightCheckForm' import { SetupView } from './components/SetupView' import { StatusBadge } from './components/StatusBadge' -import type { ReportListItem, SystemInfo, SystemStatus } from './types' +import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types' +import { UsersView } from './components/UsersView' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'about' type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error' export default function App() { @@ -20,7 +21,7 @@ export default function App() { const [system, setSystem] = useState(null) const [systemStatus, setSystemStatus] = useState(null) const [systemStatusError, setSystemStatusError] = useState('') - const [currentUser, setCurrentUser] = useState<{ username: string; role: string } | null>(null) + const [currentUser, setCurrentUser] = useState(null) const loadAppData = () => { getReports().then(setReports).catch(() => setReports([])) @@ -39,7 +40,7 @@ export default function App() { if (setup.setup_required) { setAuthMode('setup'); setCurrentUser(null); return } try { const me = await getMe() - setCurrentUser({ username: me.user.username, role: me.user.role }) + setCurrentUser(me.user) setAuthMode('app'); loadAppData() } catch (err: unknown) { if (err instanceof ApiError && err.status === 401) { setAuthMode('login'); setCurrentUser(null); return } @@ -49,7 +50,10 @@ export default function App() { } useEffect(() => { bootstrapAuth() }, []) - const handleLogout = async () => { await logout(); setCurrentUser(null); setAuthMode('login') } + const handleLogout = async () => { + try { await logout() } catch { setAuthError('Logout request failed, but local session was cleared.') } + setCurrentUser(null); setAuthMode('login'); setActive('dashboard'); setReports([]); setSystemStatus(null) + } const onSetupSubmit = async (payload: { username: string; email?: string; password: string; password_confirm: string }) => { setAuthError('') @@ -65,19 +69,29 @@ export default function App() { if (authMode === 'login') return if (authMode === 'error') return

{authError}
- const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.4-beta · read-only preflight checks' - const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', about: 'About RouteForge' }[active] + const systemLine = system ? `${system.name} ${system.version} · mode=${system.demo_mode ? 'DEMO' : 'LIVE'} · read_only=${String(system.read_only)}` : 'RouteForge v0.6.5-beta · read-only preflight checks' + const title = { dashboard: 'Dashboard', asn: 'ASN Check', prefix: 'Prefix Check', preflight: 'Preflight Check', reports: 'Reports', system: 'System Status', users: 'User Management', about: 'About RouteForge' }[active] const proxyStatus = systemStatusError ? 'ERROR' : 'OK' const migrationStatus = systemStatus?.database?.migration_status || 'unknown' const migrationsBlocked = migrationStatus === 'behind' || migrationStatus === 'error' + const role = (currentUser?.role || 'viewer') as UserRole + const canAccess = (view: NavKey) => { + if (role === 'admin') return true + if (role === 'operator') return view !== 'users' + return ['dashboard', 'reports', 'about'].includes(view) + } + const allowedActions = role === 'admin' ? 'You can run checks, manage users, view reports and system status.' : role === 'operator' ? 'You can run checks and view reports.' : 'You can view reports.' + return - {active === 'dashboard' &&

RouteForge v0.6.4-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} - {active === 'asn' && } - {active === 'prefix' && } - {active === 'preflight' && } + {active === 'dashboard' &&

RouteForge v0.6.5-beta

Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.

Logged in as: {currentUser?.username}
Role: {currentUser?.role}
Allowed actions: {allowedActions}
{migrationsBlocked &&
Database migrations are required before using RouteForge.
}
} + {!canAccess(active) &&
You do not have permission to access this section.
} + {active === 'asn' && canAccess('asn') && } + {active === 'prefix' && canAccess('prefix') && } + {active === 'preflight' && canAccess('preflight') && } {active === 'reports' &&

Reports

{reports.length===0 ?
Noch keine Reports vorhanden.
:
{reports.map(r=>)}
{r.summary}
}
} - {active === 'system' &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} - {active === 'about' &&

Version: v0.6.4-beta

} + {active === 'system' && canAccess('system') &&
{systemStatusError &&
{systemStatusError}
}{migrationsBlocked &&
Database migrations are required before using RouteForge.
}{systemStatus &&
Version: {systemStatus.version}
Mode: {systemStatus.mode}
API Proxy: {proxyStatus}
Migration Status: {migrationStatus}
}
} + {active === 'users' && canAccess('users') && } + {active === 'about' &&

Version: v0.6.5-beta

}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 546ab20..dd61f2c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { CheckResponse, ReportListItem, SystemInfo, SystemStatus } from './types' +import type { CheckResponse, ReportListItem, SystemInfo, SystemStatus, User, UserCreatePayload, UserUpdatePayload } from './types' const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -55,7 +55,10 @@ export const checkPreflight = (prefix: string, planned_origin_as: string) => req export const getSystemStatus = () => requestJson(apiUrl('/api/system/status'), { method: 'GET' }) export const getSetupRequired = () => requestJson<{ setup_required: boolean }>(apiUrl('/api/auth/setup-required'), { method: 'GET' }) -export const getMe = () => requestJson<{ user: { id: number; username: string; email?: string; role: string } }>(apiUrl('/api/auth/me'), { method: 'GET' }) +export const getMe = () => requestJson<{ user: User }>(apiUrl('/api/auth/me'), { method: 'GET' }) export const setupAdmin = (payload: { username: string; email?: string; password: string; password_confirm: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/setup'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) export const login = (payload: { username: string; password: string }) => requestJson<{ user?: { id: number; username: string } }>(apiUrl('/api/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) export const logout = () => requestJson<{ ok: boolean }>(apiUrl('/api/auth/logout'), { method: 'POST' }) +export const listUsers = () => requestJson(apiUrl('/api/users'), { method: 'GET' }) +export const createUser = (payload: UserCreatePayload) => requestJson(apiUrl('/api/users'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) +export const updateUser = (userId: number, payload: UserUpdatePayload) => requestJson(apiUrl(`/api/users/${userId}`), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 37cd443..20c46d8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' +import type { UserRole } from '../types' -type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'about' +type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'system' | 'users' | 'about' const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'dashboard', label: 'Dashboard', desc: 'Overview & quick actions' }, @@ -9,10 +10,17 @@ const nav: { key: NavKey; label: string; desc: string }[] = [ { key: 'preflight', label: 'Preflight', desc: 'Planned prefix-origin validation' }, { key: 'reports', label: 'Reports', desc: 'History and outcomes' }, { key: 'system', label: 'System', desc: 'Operational checks' }, + { key: 'users', label: 'Users', desc: 'Admin user management' }, { key: 'about', label: 'About', desc: 'Data sources and limits' }, ] -export function Layout({ children, active, onNav, systemLine, title, demoMode, currentUser, onLogout }: { children: ReactNode; active: NavKey; onNav: (key: NavKey) => void; systemLine: string; title: string; demoMode: boolean; currentUser?: { username: string; role: string } | null; onLogout: () => void }) { +export function Layout({ children, active, onNav, systemLine, title, demoMode, currentUser, onLogout }: { children: ReactNode; active: NavKey; onNav: (key: NavKey) => void; systemLine: string; title: string; demoMode: boolean; currentUser?: { username: string; role: UserRole } | null; onLogout: () => void }) { + const visibleNav = nav.filter((item) => { + if (!currentUser) return ['dashboard', 'about'].includes(item.key) + if (currentUser.role === 'admin') return true + if (currentUser.role === 'operator') return item.key !== 'users' + return ['dashboard', 'reports', 'about'].includes(item.key) + }) return
@@ -35,7 +43,7 @@ export function Layout({ children, active, onNav, systemLine, title, demoMode, c {currentUser && Angemeldet als {currentUser.username} · {currentUser.role}} {demoMode ? 'DEMO' : 'LIVE'} READ-ONLY - v0.6.4-beta + v0.6.5-beta diff --git a/frontend/src/components/UsersView.tsx b/frontend/src/components/UsersView.tsx new file mode 100644 index 0000000..85deef2 --- /dev/null +++ b/frontend/src/components/UsersView.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import { ApiError, createUser, listUsers, updateUser } from '../api' +import type { User, UserRole } from '../types' + +const ROLES: UserRole[] = ['admin', 'operator', 'viewer'] + +export function UsersView() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'viewer' as UserRole }) + + const load = async () => { + setLoading(true) + setError('') + try { setUsers(await listUsers()) } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load users') } finally { setLoading(false) } + } + + useEffect(() => { load() }, []) + + const onCreate = async () => { + if (!createForm.username.trim() || !createForm.password) { setError('Username and password are required.'); return } + setError(''); setSuccess('') + try { + await createUser({ username: createForm.username.trim(), email: createForm.email || undefined, password: createForm.password, role: createForm.role }) + setCreateForm({ username: '', email: '', password: '', role: 'viewer' }) + setSuccess('User created.') + await load() + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Create failed') } + } + + const onPatch = async (user: User, patch: Partial & { password?: string }) => { + setError(''); setSuccess('') + try { + await updateUser(user.id, { email: patch.email ?? user.email ?? null, role: (patch.role as UserRole) ?? user.role, is_active: patch.is_active ?? user.is_active, password: patch.password || undefined }) + setSuccess(`Updated ${user.username}.`) + await load() + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Update failed') } + } + + return
+

User Management

+ {error &&
{error}
} + {success &&
{success}
} +
+ setCreateForm({ ...createForm, username: e.target.value })} /> + setCreateForm({ ...createForm, email: e.target.value })} /> + setCreateForm({ ...createForm, password: e.target.value })} /> +
+ + +
+
+ {loading ?
Loading users…
:
{users.map(u => )}
UserRoleStatusActions
{u.username}
{u.email || '—'}
{u.role}{u.is_active ? 'active' : 'inactive'}
} +
+} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b8c0863..1b18e33 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -91,3 +91,30 @@ export type SystemStatus = { features?: SystemFeatures security_warnings?: string[] } + +export type UserRole = 'admin' | 'operator' | 'viewer' + +export type User = { + id: number + username: string + email?: string | null + role: UserRole + is_active?: boolean + created_at?: string + updated_at?: string + last_login_at?: string | null +} + +export type UserCreatePayload = { + username: string + email?: string + password: string + role: UserRole +} + +export type UserUpdatePayload = { + email?: string | null + role?: UserRole + is_active?: boolean + password?: string +}