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)
This commit is contained in:
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
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 = APIRouter(prefix="/api")
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
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(customers.router, prefix="/customers", tags=["customers"])
|
||||||
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
|
api_router.include_router(satellites.router, prefix="/satellites", tags=["satellites"])
|
||||||
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
api_router.include_router(servers.router, prefix="/servers", tags=["servers"])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -14,6 +14,7 @@ const navItems = [
|
|||||||
{ name: 'satellites', label: 'Satelliten' },
|
{ name: 'satellites', label: 'Satelliten' },
|
||||||
{ name: 'servers', label: 'Server' },
|
{ name: 'servers', label: 'Server' },
|
||||||
{ name: 'updates', label: 'Updates' },
|
{ name: 'updates', label: 'Updates' },
|
||||||
|
{ name: 'users', label: 'Benutzer' },
|
||||||
{ name: 'audit', label: 'Audit' },
|
{ name: 'audit', label: 'Audit' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ const router = createRouter({
|
|||||||
name: 'audit',
|
name: 'audit',
|
||||||
component: () => import('@/views/AuditView.vue'),
|
component: () => import('@/views/AuditView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
name: 'users',
|
||||||
|
component: () => import('@/views/UsersView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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<User[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchUsers(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<User[]>('/api/users')
|
||||||
|
users.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(payload: UserCreatePayload): Promise<User> {
|
||||||
|
const { data } = await apiClient.post<User>('/api/users', payload)
|
||||||
|
users.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(id: number, payload: UserUpdatePayload): Promise<User> {
|
||||||
|
const { data } = await apiClient.patch<User>(`/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 }
|
||||||
|
})
|
||||||
@@ -81,3 +81,15 @@ export interface AuditEntry {
|
|||||||
details: string | null
|
details: string | null
|
||||||
ip_address: 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,27 @@ import { useCustomersStore } from '@/stores/customers'
|
|||||||
|
|
||||||
const store = useCustomersStore()
|
const store = useCustomersStore()
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
const form = reactive({ name: '', slug: '', notes: '' })
|
const form = reactive({ name: '', slug: '', notes: '' })
|
||||||
|
|
||||||
onMounted(() => store.fetchCustomers())
|
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<void> {
|
async function submit(): Promise<void> {
|
||||||
|
formError.value = null
|
||||||
|
try {
|
||||||
await store.createCustomer({ ...form })
|
await store.createCustomer({ ...form })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
formError.value = errorMessage(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
form.name = ''
|
form.name = ''
|
||||||
form.slug = ''
|
form.slug = ''
|
||||||
@@ -35,6 +50,8 @@ async function remove(id: number): Promise<void> {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formError" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ formError }}</div>
|
||||||
|
|
||||||
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
||||||
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const store = useSatellitesStore()
|
|||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
const newKey = ref<SatelliteCreated | null>(null)
|
const newKey = ref<SatelliteCreated | null>(null)
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
|
||||||
const customerId = computed(() => customersStore.selectedId)
|
const customerId = computed(() => customersStore.selectedId)
|
||||||
|
|
||||||
@@ -22,9 +23,27 @@ watch(customerId, async (id) => {
|
|||||||
if (id) await store.fetchSatellites(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<void> {
|
async function create(): Promise<void> {
|
||||||
if (!customerId.value || !newName.value) return
|
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)
|
newKey.value = await store.createSatellite(customerId.value, newName.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
formError.value = errorMessage(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +76,8 @@ function isOnline(lastSeen: string | null): boolean {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="mb-6 text-2xl font-bold">Satelliten</h1>
|
<h1 class="mb-6 text-2xl font-bold">Satelliten</h1>
|
||||||
|
|
||||||
|
<div v-if="formError" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ formError }}</div>
|
||||||
|
|
||||||
<div v-if="newKey" class="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-5">
|
<div v-if="newKey" class="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-5">
|
||||||
<p class="mb-2 font-semibold text-amber-800">
|
<p class="mb-2 font-semibold text-amber-800">
|
||||||
API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt!
|
API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt!
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const store = useServersStore()
|
|||||||
const updatesStore = useUpdatesStore()
|
const updatesStore = useUpdatesStore()
|
||||||
|
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
@@ -36,14 +37,31 @@ watch(customerId, async (id) => {
|
|||||||
if (id) await store.fetchServers(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<void> {
|
async function submit(): Promise<void> {
|
||||||
if (!customerId.value) return
|
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({
|
await store.createServer({
|
||||||
...form,
|
...form,
|
||||||
customer_id: customerId.value,
|
customer_id: customerId.value,
|
||||||
credential_ref: form.credential_ref || null,
|
credential_ref: form.credential_ref || null,
|
||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
})
|
})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
formError.value = errorMessage(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
form.name = ''
|
form.name = ''
|
||||||
form.hostname = ''
|
form.hostname = ''
|
||||||
@@ -78,6 +96,8 @@ async function healthCheck(id: number): Promise<void> {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formError" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ formError }}</div>
|
||||||
|
|
||||||
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
||||||
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
const store = useUsersStore()
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
const actionError = ref<string | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
is_admin: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPw = reactive<Record<number, string>>({})
|
||||||
|
const editing = ref<number | null>(null)
|
||||||
|
const editForm = reactive({ full_name: '', email: '' })
|
||||||
|
|
||||||
|
onMounted(() => store.fetchUsers().catch((e: unknown) => {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
}))
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
formError.value = null
|
||||||
|
try {
|
||||||
|
await store.createUser({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
full_name: form.full_name || null,
|
||||||
|
email: form.email || null,
|
||||||
|
is_admin: form.is_admin,
|
||||||
|
})
|
||||||
|
showForm.value = false
|
||||||
|
form.username = ''
|
||||||
|
form.password = ''
|
||||||
|
form.full_name = ''
|
||||||
|
form.email = ''
|
||||||
|
form.is_admin = false
|
||||||
|
} catch (e: unknown) {
|
||||||
|
formError.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(user: User): Promise<void> {
|
||||||
|
actionError.value = null
|
||||||
|
try {
|
||||||
|
await store.updateUser(user.id, { is_active: !user.is_active })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAdmin(user: User): Promise<void> {
|
||||||
|
actionError.value = null
|
||||||
|
try {
|
||||||
|
await store.updateUser(user.id, { is_admin: !user.is_admin })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(user: User): void {
|
||||||
|
editing.value = user.id
|
||||||
|
editForm.full_name = user.full_name ?? ''
|
||||||
|
editForm.email = user.email ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(user: User): Promise<void> {
|
||||||
|
actionError.value = null
|
||||||
|
try {
|
||||||
|
await store.updateUser(user.id, {
|
||||||
|
full_name: editForm.full_name || null,
|
||||||
|
email: editForm.email || null,
|
||||||
|
})
|
||||||
|
editing.value = null
|
||||||
|
} catch (e: unknown) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPassword(user: User): Promise<void> {
|
||||||
|
actionError.value = null
|
||||||
|
const pw = resetPw[user.id]
|
||||||
|
if (!pw || pw.length < 8) {
|
||||||
|
actionError.value = 'Passwort muss mindestens 8 Zeichen haben'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await store.updateUser(user.id, { password: pw })
|
||||||
|
resetPw[user.id] = ''
|
||||||
|
} catch (e: unknown) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleString('de-DE')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Benutzer</h1>
|
||||||
|
<button
|
||||||
|
class="rounded bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
|
||||||
|
@click="showForm = !showForm"
|
||||||
|
>
|
||||||
|
{{ showForm ? 'Abbrechen' : 'Benutzer hinzufügen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formError" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ formError }}</div>
|
||||||
|
<div v-if="actionError" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ actionError }}</div>
|
||||||
|
|
||||||
|
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
|
||||||
|
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Benutzername</label>
|
||||||
|
<input v-model="form.username" required class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Passwort (min. 8 Zeichen)</label>
|
||||||
|
<input v-model="form.password" type="password" required minlength="8" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Anzeigename</label>
|
||||||
|
<input v-model="form.full_name" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">E-Mail</label>
|
||||||
|
<input v-model="form.email" type="email" class="w-full rounded border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 sm:col-span-2">
|
||||||
|
<input id="new-is-admin" v-model="form.is_admin" type="checkbox" class="rounded border-slate-300" />
|
||||||
|
<label for="new-is-admin" class="text-sm font-medium">Administrator</label>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<button type="submit" class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Benutzername</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Name</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Typ</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Admin</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Aktiv</th>
|
||||||
|
<th class="px-4 py-2 text-left font-medium">Letzter Login</th>
|
||||||
|
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
<tr v-for="user in store.users" :key="user.id">
|
||||||
|
<td class="px-4 py-2 font-medium">{{ user.username }}</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<template v-if="editing === user.id">
|
||||||
|
<input v-model="editForm.full_name" class="w-32 rounded border-slate-300" placeholder="Name" />
|
||||||
|
<input v-model="editForm.email" class="mt-1 w-32 rounded border-slate-300" placeholder="E-Mail" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ user.full_name || '-' }}
|
||||||
|
<div v-if="user.email" class="text-xs text-slate-500">{{ user.email }}</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span
|
||||||
|
class="rounded px-2 py-0.5 text-xs"
|
||||||
|
:class="user.is_ldap ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'"
|
||||||
|
>
|
||||||
|
{{ user.is_ldap ? 'LDAP' : 'Lokal' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-0.5 text-xs font-semibold"
|
||||||
|
:class="user.is_admin ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'"
|
||||||
|
@click="toggleAdmin(user)"
|
||||||
|
>
|
||||||
|
{{ user.is_admin ? 'Ja' : 'Nein' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-2 py-0.5 text-xs font-semibold"
|
||||||
|
:class="user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'"
|
||||||
|
@click="toggleActive(user)"
|
||||||
|
>
|
||||||
|
{{ user.is_active ? 'Aktiv' : 'Inaktiv' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-xs text-slate-500">{{ formatDate(user.last_login_at) }}</td>
|
||||||
|
<td class="px-4 py-2 text-right">
|
||||||
|
<template v-if="editing === user.id">
|
||||||
|
<button class="mr-2 rounded bg-green-700 px-2 py-1 text-xs text-white hover:bg-green-600" @click="saveEdit(user)">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button class="rounded bg-slate-200 px-2 py-1 text-xs hover:bg-slate-300" @click="editing = null">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500" @click="startEdit(user)">
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<span v-if="!user.is_ldap" class="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
v-model="resetPw[user.id]"
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
class="w-32 rounded border-slate-300 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="resetPw[user.id]"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs text-white hover:bg-slate-700"
|
||||||
|
@click="setPassword(user)"
|
||||||
|
>
|
||||||
|
Setzen
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="store.users.length === 0">
|
||||||
|
<td colspan="7" class="px-4 py-6 text-center text-slate-500">Keine Benutzer vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user