From d1a757d72b4bccd4175ffee13d50b3074c46fc1f Mon Sep 17 00:00:00 2001 From: B0rbor4d Date: Tue, 29 Sep 2026 01:25:04 +0000 Subject: [PATCH] Benutzerverwaltung + Fehleranzeige in Formularen - Backend: /api/users (LIST/CREATE/PATCH, admin-only) mit Self-Protection (Admin kann sich nicht selbst deaktivieren/entziehen), lokale User anlegbar, LDAP-User verwaltbar (aktiv/admin), Passwort-Reset nur lokal - Frontend: Benutzer-View (Liste, anlegen, toggles, Passwort setzen), Nav-Eintrag + Route - Bugfix 'Speichern tut nichts': submit() brach stillschweigend ab wenn kein Kunde gewaehlt war oder die API einen Fehler lieferte - jetzt wird eine Fehlermeldung angezeigt (Servers, Customers, Satellites) --- backend/app/api/__init__.py | 3 +- backend/app/api/routes/users.py | 101 +++++++++++ backend/app/schemas/user.py | 35 ++++ frontend/src/components/AppLayout.vue | 1 + frontend/src/router/index.ts | 5 + frontend/src/stores/users.ts | 50 ++++++ frontend/src/types.ts | 12 ++ frontend/src/views/CustomersView.vue | 19 +- frontend/src/views/SatellitesView.vue | 25 ++- frontend/src/views/ServersView.vue | 34 +++- frontend/src/views/UsersView.vue | 250 ++++++++++++++++++++++++++ 11 files changed, 524 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/routes/users.py create mode 100644 backend/app/schemas/user.py create mode 100644 frontend/src/stores/users.ts create mode 100644 frontend/src/views/UsersView.vue diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e1276e3..7fad958 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates +from app.api.routes import audit, auth, customers, satellite_api, satellites, servers, updates, users api_router = APIRouter(prefix="/api") api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(customers.router, prefix="/customers", tags=["customers"]) api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"]) api_router.include_router(servers.router, prefix="/servers", tags=["servers"]) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py new file mode 100644 index 0000000..66e99cd --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,101 @@ +"""Dashboard user management (admin only). + +LDAP users are provisioned automatically on first login; they can be +deactivated or promoted here, but their password always lives in AD. +Local users can additionally be created and re-passworded. +""" + +from fastapi import APIRouter, Depends, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import client_ip, require_admin +from app.core.database import get_db +from app.core.exceptions import ConflictError, NotFoundError, ValidationError +from app.core.security import hash_password +from app.models.user import User +from app.schemas.user import UserCreate, UserRead, UserUpdate +from app.services.audit import AuditService + +router = APIRouter() + + +@router.get("", response_model=list[UserRead]) +async def list_users( + db: AsyncSession = Depends(get_db), + _admin: User = Depends(require_admin), +) -> list[User]: + result = await db.execute(select(User).order_by(User.username)) + return list(result.scalars().all()) + + +@router.post("", response_model=UserRead, status_code=201) +async def create_user( + payload: UserCreate, + request: Request, + db: AsyncSession = Depends(get_db), + admin: User = Depends(require_admin), +) -> User: + existing = await db.execute(select(User).where(User.username == payload.username)) + if existing.scalar_one_or_none(): + raise ConflictError("Benutzername ist bereits vergeben") + + user = User( + username=payload.username, + email=payload.email, + full_name=payload.full_name, + password_hash=hash_password(payload.password), + is_ldap=False, + is_admin=payload.is_admin, + ) + db.add(user) + await db.flush() + await AuditService(db).log( + username=admin.username, + action="user.create", + target=user.username, + ip_address=client_ip(request), + ) + return user + + +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: int, + payload: UserUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + admin: User = Depends(require_admin), +) -> User: + user = await db.get(User, user_id) + if not user: + raise NotFoundError("Benutzer nicht gefunden") + + # Self-protection: an admin must not lock themselves out + if user.id == admin.id: + if payload.is_active is False: + raise ValidationError("Du kannst dich nicht selbst deaktivieren") + if payload.is_admin is False: + raise ValidationError("Du kannst dir nicht selbst die Adminrechte entziehen") + + if payload.is_admin is not None: + user.is_admin = payload.is_admin + if payload.is_active is not None: + user.is_active = payload.is_active + if payload.full_name is not None: + user.full_name = payload.full_name + if payload.email is not None: + user.email = payload.email + if payload.password is not None: + if user.is_ldap: + raise ValidationError("LDAP-User haben kein lokales Passwort") + user.password_hash = hash_password(payload.password) + + await AuditService(db).log( + username=admin.username, + action="user.update", + target=user.username, + details={"fields": sorted(payload.model_dump(exclude_unset=True).keys())}, + ip_address=client_ip(request), + ) + return user diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..9c2b08b --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,35 @@ +"""User schemas for dashboard user management.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class UserRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + email: str | None + full_name: str | None + is_ldap: bool + is_admin: bool + is_active: bool + last_login_at: datetime | None + created_at: datetime + + +class UserCreate(BaseModel): + username: str = Field(min_length=2, max_length=255) + password: str = Field(min_length=8, max_length=72) + email: str | None = None + full_name: str | None = None + is_admin: bool = False + + +class UserUpdate(BaseModel): + email: str | None = None + full_name: str | None = None + is_admin: bool | None = None + is_active: bool | None = None + password: str | None = Field(default=None, min_length=8, max_length=72) diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index c8e9240..4712bde 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -14,6 +14,7 @@ const navItems = [ { name: 'satellites', label: 'Satelliten' }, { name: 'servers', label: 'Server' }, { name: 'updates', label: 'Updates' }, + { name: 'users', label: 'Benutzer' }, { name: 'audit', label: 'Audit' }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a581507..093eee7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -39,6 +39,11 @@ const router = createRouter({ name: 'audit', component: () => import('@/views/AuditView.vue'), }, + { + path: '/users', + name: 'users', + component: () => import('@/views/UsersView.vue'), + }, ], }) diff --git a/frontend/src/stores/users.ts b/frontend/src/stores/users.ts new file mode 100644 index 0000000..a018b5f --- /dev/null +++ b/frontend/src/stores/users.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient } from '@/api/client' +import type { User } from '@/types' + +export interface UserCreatePayload { + username: string + password: string + email?: string | null + full_name?: string | null + is_admin: boolean +} + +export interface UserUpdatePayload { + email?: string | null + full_name?: string | null + is_admin?: boolean + is_active?: boolean + password?: string +} + +export const useUsersStore = defineStore('users', () => { + const users = ref([]) + const loading = ref(false) + + async function fetchUsers(): Promise { + loading.value = true + try { + const { data } = await apiClient.get('/api/users') + users.value = data + } finally { + loading.value = false + } + } + + async function createUser(payload: UserCreatePayload): Promise { + const { data } = await apiClient.post('/api/users', payload) + users.value.push(data) + return data + } + + async function updateUser(id: number, payload: UserUpdatePayload): Promise { + const { data } = await apiClient.patch(`/api/users/${id}`, payload) + const idx = users.value.findIndex((u) => u.id === id) + if (idx !== -1) users.value[idx] = data + return data + } + + return { users, loading, fetchUsers, createUser, updateUser } +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f54a51a..0e8b336 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -81,3 +81,15 @@ export interface AuditEntry { details: string | null ip_address: string | null } + +export interface User { + id: number + username: string + email: string | null + full_name: string | null + is_ldap: boolean + is_admin: boolean + is_active: boolean + last_login_at: string | null + created_at: string +} diff --git a/frontend/src/views/CustomersView.vue b/frontend/src/views/CustomersView.vue index 623e65a..41dad61 100644 --- a/frontend/src/views/CustomersView.vue +++ b/frontend/src/views/CustomersView.vue @@ -4,12 +4,27 @@ import { useCustomersStore } from '@/stores/customers' const store = useCustomersStore() const showForm = ref(false) +const formError = ref(null) const form = reactive({ name: '', slug: '', notes: '' }) onMounted(() => store.fetchCustomers()) +function errorMessage(e: unknown): string { + if (typeof e === 'object' && e !== null && 'response' in e) { + const detail = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail + if (typeof detail === 'string') return detail + } + return 'Unbekannter Fehler' +} + async function submit(): Promise { - await store.createCustomer({ ...form }) + formError.value = null + try { + await store.createCustomer({ ...form }) + } catch (e: unknown) { + formError.value = errorMessage(e) + return + } showForm.value = false form.name = '' form.slug = '' @@ -35,6 +50,8 @@ async function remove(id: number): Promise { +
{{ formError }}
+
diff --git a/frontend/src/views/SatellitesView.vue b/frontend/src/views/SatellitesView.vue index 4de8e77..8ac0cd1 100644 --- a/frontend/src/views/SatellitesView.vue +++ b/frontend/src/views/SatellitesView.vue @@ -10,6 +10,7 @@ const store = useSatellitesStore() const newName = ref('') const newKey = ref(null) const copied = ref(false) +const formError = ref(null) const customerId = computed(() => customersStore.selectedId) @@ -22,9 +23,27 @@ watch(customerId, async (id) => { if (id) await store.fetchSatellites(id) }) +function errorMessage(e: unknown): string { + if (typeof e === 'object' && e !== null && 'response' in e) { + const detail = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail + if (typeof detail === 'string') return detail + } + return 'Unbekannter Fehler' +} + async function create(): Promise { - if (!customerId.value || !newName.value) return - newKey.value = await store.createSatellite(customerId.value, newName.value) + formError.value = null + if (!customerId.value) { + formError.value = 'Bitte zuerst einen Kunden anlegen und oben in der Leiste auswählen.' + return + } + if (!newName.value) return + try { + newKey.value = await store.createSatellite(customerId.value, newName.value) + } catch (e: unknown) { + formError.value = errorMessage(e) + return + } newName.value = '' } @@ -57,6 +76,8 @@ function isOnline(lastSeen: string | null): boolean {

Satelliten

+
{{ formError }}
+

API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt! diff --git a/frontend/src/views/ServersView.vue b/frontend/src/views/ServersView.vue index 8250435..078141a 100644 --- a/frontend/src/views/ServersView.vue +++ b/frontend/src/views/ServersView.vue @@ -10,6 +10,7 @@ const store = useServersStore() const updatesStore = useUpdatesStore() const showForm = ref(false) +const formError = ref(null) const form = reactive({ name: '', hostname: '', @@ -36,14 +37,31 @@ watch(customerId, async (id) => { if (id) await store.fetchServers(id) }) +function errorMessage(e: unknown): string { + if (typeof e === 'object' && e !== null && 'response' in e) { + const detail = (e as { response?: { data?: { detail?: string } } }).response?.data?.detail + if (typeof detail === 'string') return detail + } + return 'Unbekannter Fehler' +} + async function submit(): Promise { - if (!customerId.value) return - await store.createServer({ - ...form, - customer_id: customerId.value, - credential_ref: form.credential_ref || null, - description: form.description || null, - }) + formError.value = null + if (!customerId.value) { + formError.value = 'Bitte zuerst einen Kunden anlegen und oben in der Leiste auswählen.' + return + } + try { + await store.createServer({ + ...form, + customer_id: customerId.value, + credential_ref: form.credential_ref || null, + description: form.description || null, + }) + } catch (e: unknown) { + formError.value = errorMessage(e) + return + } showForm.value = false form.name = '' form.hostname = '' @@ -78,6 +96,8 @@ async function healthCheck(id: number): Promise {

+
{{ formError }}
+
diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue new file mode 100644 index 0000000..ec9e912 --- /dev/null +++ b/frontend/src/views/UsersView.vue @@ -0,0 +1,250 @@ + + +