feat(users): add user administration and password management
This commit is contained in:
parent
47f54d3461
commit
b584e60273
16 changed files with 720 additions and 22 deletions
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "202607110004"
|
||||
down_revision = "202607110003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("first_name", sa.String(length=80), nullable=True))
|
||||
op.add_column("users", sa.Column("last_name", sa.String(length=80), nullable=True))
|
||||
op.add_column("users", sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default=sa.true()))
|
||||
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("users", sa.Column("password_changed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE users SET first_name = COALESCE(first_name, split_part(full_name, ' ', 1)), "
|
||||
"last_name = COALESCE(last_name, NULLIF(substr(full_name, length(split_part(full_name, ' ', 1)) + 2), ''))"
|
||||
)
|
||||
)
|
||||
op.execute(sa.text("UPDATE users SET first_name = COALESCE(first_name, 'Validation'), last_name = COALESCE(last_name, 'Admin')"))
|
||||
op.alter_column("users", "first_name", nullable=False)
|
||||
op.alter_column("users", "last_name", nullable=False)
|
||||
op.alter_column("users", "must_change_password", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "password_changed_at")
|
||||
op.drop_column("users", "last_login_at")
|
||||
op.drop_column("users", "must_change_password")
|
||||
op.drop_column("users", "last_name")
|
||||
op.drop_column("users", "first_name")
|
||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
bearer = HTTPBearer()
|
||||
|
||||
|
|
@ -30,3 +30,17 @@ def current_user(
|
|||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
def require_role(*roles: UserRole):
|
||||
def dependency(user: User = Depends(current_user)) -> User:
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def current_admin(user: User = Depends(current_user)) -> User:
|
||||
if user.role != UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserRead
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserRead
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
|
@ -22,3 +25,24 @@ def login(payload: LoginRequest, session: Session = Depends(get_session)) -> Tok
|
|||
def me(user: User = Depends(current_user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
user: User = Depends(current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=422, detail="Das aktuelle Passwort ist falsch.")
|
||||
if payload.new_password != payload.new_password_confirmation:
|
||||
raise HTTPException(status_code=422, detail="Die neuen Passwoerter stimmen nicht ueberein.")
|
||||
if len(payload.new_password) < 12:
|
||||
raise HTTPException(status_code=422, detail="Das neue Passwort muss mindestens 12 Zeichen haben.")
|
||||
if verify_password(payload.new_password, user.password_hash):
|
||||
raise HTTPException(status_code=422, detail="Das neue Passwort darf nicht identisch zum alten sein.")
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -9,18 +9,20 @@ import uuid
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import current_user
|
||||
from app.api.dependencies import current_admin, current_user
|
||||
from app.db.session import get_session
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.validation import Validation
|
||||
from app.modules.helios.service import HeliosImportService
|
||||
from app.modules.orion.service import OrionReportService
|
||||
|
|
@ -50,6 +52,10 @@ from app.schemas.domain import (
|
|||
ValidationRead,
|
||||
ValidationReview,
|
||||
ValidationUpdate,
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserPasswordResetRequest,
|
||||
)
|
||||
from app.services.domain_service import CrudService, DomainServices
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
|
|
@ -546,3 +552,155 @@ def validation_report_pdf(item_id: str, session: Session = Depends(get_session))
|
|||
media_type="application/pdf",
|
||||
filename=report_path.name,
|
||||
)
|
||||
def _user_payload(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"must_change_password": user.must_change_password,
|
||||
"last_login_at": user.last_login_at,
|
||||
"password_changed_at": user.password_changed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users", response_model=PaginatedResponse[UserRead])
|
||||
def list_users(
|
||||
session: Session = Depends(get_session),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(default=None),
|
||||
role: str | None = Query(default=None),
|
||||
active: bool | None = Query(default=None),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_order: str = Query(default="desc"),
|
||||
_: User = Depends(current_admin),
|
||||
):
|
||||
query = select(User)
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
query = query.where(
|
||||
(User.first_name.ilike(term))
|
||||
| (User.last_name.ilike(term))
|
||||
| (User.email.ilike(term))
|
||||
)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
if active is not None:
|
||||
query = query.where(User.is_active.is_(active))
|
||||
sort_columns = {
|
||||
"name": User.last_name,
|
||||
"email": User.email,
|
||||
"role": User.role,
|
||||
"is_active": User.is_active,
|
||||
"last_login_at": User.last_login_at,
|
||||
"password_changed_at": User.password_changed_at,
|
||||
"created_at": User.created_at,
|
||||
"updated_at": User.updated_at,
|
||||
}
|
||||
sort_column = sort_columns.get(sort_by, User.created_at)
|
||||
query = query.order_by(sort_column.asc() if sort_order.lower() == "asc" else sort_column.desc())
|
||||
total = session.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
items = list(session.scalars(query.offset((page - 1) * page_size).limit(page_size)))
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.post("/users", status_code=201)
|
||||
def create_user(payload: UserCreate, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
from app.core.security import hash_password
|
||||
import secrets
|
||||
from app.models.user import User
|
||||
|
||||
temporary_password = payload.temporary_password or secrets.token_urlsafe(12)
|
||||
user = User(
|
||||
email=payload.email.lower(),
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
role=payload.role.value,
|
||||
password_hash=hash_password(temporary_password),
|
||||
is_active=payload.is_active,
|
||||
must_change_password=payload.must_change_password,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return JSONResponse(content={"temporary_password": temporary_password, "user": jsonable_encoder(user)}, status_code=201)
|
||||
|
||||
|
||||
@router.get("/users/{item_id}", response_model=UserRead)
|
||||
def get_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/users/{item_id}", response_model=UserRead)
|
||||
def update_user(item_id: str, payload: UserUpdate, session: Session = Depends(get_session), me: User = Depends(current_admin)):
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
if user.id == me.id and not payload.is_active:
|
||||
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
|
||||
active_admins = session.scalar(
|
||||
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
|
||||
) or 0
|
||||
if user.role == UserRole.ADMIN.value and active_admins <= 1 and (not payload.is_active or payload.role != UserRole.ADMIN.value):
|
||||
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert oder herabgestuft werden.")
|
||||
user.first_name = payload.first_name
|
||||
user.last_name = payload.last_name
|
||||
user.email = payload.email.lower()
|
||||
user.role = payload.role.value
|
||||
user.is_active = payload.is_active
|
||||
user.must_change_password = payload.must_change_password
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users/{item_id}/deactivate", response_model=UserRead)
|
||||
def deactivate_user(item_id: str, session: Session = Depends(get_session), me: User = Depends(current_admin)):
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
if user.id == me.id:
|
||||
raise HTTPException(status_code=409, detail="Own admin account cannot be deactivated")
|
||||
active_admins = session.scalar(
|
||||
select(func.count()).select_from(User).where(User.role == UserRole.ADMIN.value, User.is_active.is_(True))
|
||||
) or 0
|
||||
if user.role == UserRole.ADMIN.value and active_admins <= 1:
|
||||
raise HTTPException(status_code=409, detail="Der letzte aktive ADMIN darf nicht deaktiviert werden.")
|
||||
user.is_active = False
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users/{item_id}/activate", response_model=UserRead)
|
||||
def activate_user(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
user.is_active = True
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users/{item_id}/reset-password")
|
||||
def reset_user_password(item_id: str, session: Session = Depends(get_session), _: User = Depends(current_admin)):
|
||||
import secrets
|
||||
from app.core.security import hash_password
|
||||
|
||||
user = session.get(User, item_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
temporary_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(temporary_password)
|
||||
user.must_change_password = True
|
||||
session.commit()
|
||||
return JSONResponse(content={"temporary_password": temporary_password})
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ def seed_admin() -> None:
|
|||
session.add(
|
||||
User(
|
||||
email=settings.admin_email.lower(),
|
||||
full_name="Validation Suite Administrator",
|
||||
role=UserRole.admin,
|
||||
first_name="Validation Suite",
|
||||
last_name="Administrator",
|
||||
role=UserRole.ADMIN.value,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
|
@ -27,4 +29,3 @@ def seed_admin() -> None:
|
|||
|
||||
if __name__ == "__main__":
|
||||
seed_admin()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Enum, String
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
admin = "admin"
|
||||
employee = "employee"
|
||||
auditor = "auditor"
|
||||
ADMIN = "admin"
|
||||
PRUEFER = "pruefer"
|
||||
MITARBEITER = "mitarbeiter"
|
||||
LESER = "leser"
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(160))
|
||||
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.employee)
|
||||
first_name: Mapped[str] = mapped_column(String(80))
|
||||
last_name: Mapped[str] = mapped_column(String(80))
|
||||
role: Mapped[str] = mapped_column(String(40), default=UserRole.MITARBEITER.value)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
|
@ -18,7 +20,16 @@ class TokenResponse(BaseModel):
|
|||
|
||||
class UserRead(EntityRead):
|
||||
email: EmailStr
|
||||
full_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
must_change_password: bool
|
||||
last_login_at: datetime | None = None
|
||||
password_changed_at: datetime | None = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
new_password_confirmation: str
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import EmailStr, Field, field_validator
|
||||
|
||||
from app.models.customer import CustomerType
|
||||
from app.models.equipment import EquipmentKind, EquipmentStatus
|
||||
from app.models.user import UserRole
|
||||
from app.models.validation import ValidationStatus
|
||||
from app.schemas.common import EntityRead, ORMModel
|
||||
|
||||
|
|
@ -241,3 +243,29 @@ class MeasurementImportValueConfirm(ORMModel):
|
|||
|
||||
class MeasurementImportConfirmRequest(ORMModel):
|
||||
values: list[MeasurementImportValueConfirm]
|
||||
|
||||
|
||||
class UserBase(ORMModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: EmailStr
|
||||
role: UserRole
|
||||
is_active: bool = True
|
||||
must_change_password: bool = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
temporary_password: str
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserRead(UserBase, EntityRead):
|
||||
last_login_at: datetime | None = None
|
||||
password_changed_at: datetime | None = None
|
||||
|
||||
|
||||
class UserPasswordResetRequest(ORMModel):
|
||||
temporary_password: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -19,5 +21,6 @@ class AuthService:
|
|||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return create_access_token(user.id, user.role.value)
|
||||
|
||||
user.last_login_at = datetime.now(UTC)
|
||||
self.users.session.commit()
|
||||
return create_access_token(user.id, user.role)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.models.customer import Customer
|
|||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.validation import Validation
|
||||
from app.repositories.base import Repository
|
||||
from app.repositories.domain import (
|
||||
|
|
@ -20,6 +21,7 @@ from app.repositories.domain import (
|
|||
DeviceRepository,
|
||||
EquipmentRepository,
|
||||
LocationRepository,
|
||||
UserRepository,
|
||||
ValidationRepository,
|
||||
)
|
||||
|
||||
|
|
@ -78,6 +80,7 @@ class CrudService:
|
|||
|
||||
class DomainServices:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.users: CrudService[User] = CrudService(UserRepository(session))
|
||||
self.customers: CrudService[Customer] = CrudService(CustomerRepository(session))
|
||||
self.locations: CrudService[Location] = CrudService(LocationRepository(session))
|
||||
self.contacts: CrudService[Contact] = CrudService(ContactRepository(session))
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ from app.db.base import Base
|
|||
from app.models.customer import Customer, CustomerType
|
||||
from app.models.contact import Contact
|
||||
from app.models.device import Device
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation, ValidationStatus
|
||||
from app.services.validation_workflow import ValidationWorkflowService
|
||||
from app.services.auth_service import AuthService
|
||||
from app.schemas.domain import ValidationCreate
|
||||
from app.modules.orion.service import OrionReportService
|
||||
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
|
||||
|
|
@ -187,6 +189,46 @@ def test_invalid_uuid_returns_validation_error():
|
|||
raise AssertionError("Invalid UUID was accepted")
|
||||
|
||||
|
||||
def test_user_model_supports_password_management_fields():
|
||||
db = session()
|
||||
user = User(
|
||||
email="user@schubamed.de",
|
||||
first_name="Max",
|
||||
last_name="Mustermann",
|
||||
role=UserRole.PRUEFER.value,
|
||||
password_hash="hash",
|
||||
is_active=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
assert user.full_name == "Max Mustermann"
|
||||
assert user.must_change_password is True
|
||||
assert user.role == UserRole.PRUEFER
|
||||
|
||||
|
||||
def test_login_updates_last_login_at():
|
||||
from app.core.security import hash_password
|
||||
|
||||
db = session()
|
||||
user = User(
|
||||
email="login@schubamed.de",
|
||||
first_name="Login",
|
||||
last_name="Test",
|
||||
role=UserRole.MITARBEITER.value,
|
||||
password_hash=hash_password("VerySecretPass123"),
|
||||
is_active=True,
|
||||
must_change_password=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
AuthService(db).login("login@schubamed.de", "VerySecretPass123")
|
||||
|
||||
assert user.last_login_at is not None
|
||||
|
||||
|
||||
def test_revalidation_date_uses_calendar_months():
|
||||
db = session()
|
||||
customer, location, device = seed(db)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
current_password: z.string().min(1, "Aktuelles Passwort erforderlich"),
|
||||
new_password: z.string().min(12, "Mindestens 12 Zeichen"),
|
||||
new_password_confirmation: z.string().min(12, "Mindestens 12 Zeichen")
|
||||
}).refine((values) => values.new_password === values.new_password_confirmation, {
|
||||
path: ["new_password_confirmation"],
|
||||
message: "Die neuen Passwoerter stimmen nicht ueberein"
|
||||
});
|
||||
|
||||
type SecurityForm = z.infer<typeof schema>;
|
||||
|
||||
export default function SecurityPage() {
|
||||
const { token, refreshUser } = useAuth();
|
||||
const [message, setMessage] = useState("");
|
||||
const form = useForm<SecurityForm>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { current_password: "", new_password: "", new_password_confirmation: "" }
|
||||
});
|
||||
|
||||
async function onSubmit(values: SecurityForm) {
|
||||
await apiFetch("/auth/change-password", token ?? "", { method: "POST", body: JSON.stringify(values) });
|
||||
setMessage("Passwort erfolgreich geaendert");
|
||||
form.reset();
|
||||
refreshUser();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-semibold text-text">Sicherheit</h1>
|
||||
<p className="mt-2 text-text-light">Hier aenderst du dein Passwort.</p>
|
||||
</header>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Aktuelles Passwort</span>
|
||||
<input type="password" {...form.register("current_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Neues Passwort</span>
|
||||
<input type="password" {...form.register("new_password")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Neues Passwort wiederholen</span>
|
||||
<input type="password" {...form.register("new_password_confirmation")} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
|
||||
</label>
|
||||
{message && <div className="rounded-lg border border-success/30 bg-white p-3 text-sm text-success shadow-soft">{message}</div>}
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn btn-primary h-12"><KeyRound className="h-4 w-4" /> Passwort ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
validation-suite/frontend/atlas/app/(app)/users/page.tsx
Normal file
251
validation-suite/frontend/atlas/app/(app)/users/page.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, KeyRound, RotateCcw, Search, UserPlus2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { apiDelete, apiFetch, apiGet, Paginated, User } from "@/lib/api";
|
||||
|
||||
const schema = z.object({
|
||||
first_name: z.string().min(1, "Vorname erforderlich"),
|
||||
last_name: z.string().min(1, "Nachname erforderlich"),
|
||||
email: z.string().email("Gueltige E-Mail erforderlich"),
|
||||
role: z.enum(["admin", "pruefer", "mitarbeiter", "leser"]),
|
||||
is_active: z.boolean().default(true),
|
||||
must_change_password: z.boolean().default(true),
|
||||
temporary_password: z.string().min(12, "Mindestens 12 Zeichen")
|
||||
});
|
||||
|
||||
type UserForm = z.infer<typeof schema>;
|
||||
|
||||
const roleLabels: Record<User["role"], string> = {
|
||||
admin: "ADMIN",
|
||||
pruefer: "PRUEFER",
|
||||
mitarbeiter: "MITARBEITER",
|
||||
leser: "LESER"
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const { token, user } = useAuth();
|
||||
const client = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [active, setActive] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [editing, setEditing] = useState<User | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const form = useForm<UserForm>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
role: "mitarbeiter",
|
||||
is_active: true,
|
||||
must_change_password: true,
|
||||
temporary_password: ""
|
||||
}
|
||||
});
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => ["users", token, page, search, role, active, sortBy, sortOrder],
|
||||
[token, page, search, role, active, sortBy, sortOrder]
|
||||
);
|
||||
|
||||
const users = useQuery({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
apiGet<Paginated<User>>(
|
||||
`/users?page=${page}&page_size=10&search=${encodeURIComponent(search)}&role=${encodeURIComponent(role)}&active=${encodeURIComponent(active)}&sort_by=${encodeURIComponent(sortBy)}&sort_order=${sortOrder}`,
|
||||
token ?? ""
|
||||
),
|
||||
enabled: Boolean(token)
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: UserForm) => {
|
||||
const payload = {
|
||||
...values,
|
||||
email: values.email.toLowerCase()
|
||||
};
|
||||
if (editing) {
|
||||
return apiFetch<User>(`/users/${editing.id}`, token ?? "", { method: "PUT", body: JSON.stringify(payload) });
|
||||
}
|
||||
return apiFetch<{ temporary_password: string; user: User }>(`/users`, token ?? "", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
onSuccess: async (created) => {
|
||||
if (!editing) {
|
||||
setTemporaryPassword((created as { temporary_password?: string }).temporary_password ?? "");
|
||||
}
|
||||
setMessage(editing ? "Benutzer gespeichert" : "Benutzer angelegt");
|
||||
setError("");
|
||||
setEditing(null);
|
||||
form.reset();
|
||||
await client.invalidateQueries({ queryKey: ["users"] });
|
||||
},
|
||||
onError: (err: Error) => setError(err.message)
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/deactivate`, token ?? "", { method: "POST" }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
|
||||
});
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (item: User) => apiFetch<User>(`/users/${item.id}/activate`, token ?? "", { method: "POST" }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ["users"] })
|
||||
});
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (item: User) => apiFetch<{ temporary_password: string }>(`/users/${item.id}/reset-password`, token ?? "", { method: "POST" }),
|
||||
onSuccess: (result) => {
|
||||
setTemporaryPassword(result.temporary_password);
|
||||
setMessage("Temporäres Passwort erzeugt");
|
||||
}
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <div className="rounded-lg border border-border bg-surface p-6 text-text-light">Nur fuer Administratoren verfuegbar.</div>;
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditing(null);
|
||||
form.reset({ first_name: "", last_name: "", email: "", role: "mitarbeiter", is_active: true, must_change_password: true, temporary_password: "" });
|
||||
}
|
||||
|
||||
function startEdit(item: User) {
|
||||
setEditing(item);
|
||||
form.reset({
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
email: item.email,
|
||||
role: item.role,
|
||||
is_active: item.is_active,
|
||||
must_change_password: item.must_change_password,
|
||||
temporary_password: ""
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-text">Benutzer</h1>
|
||||
<p className="mt-2 text-text-light">Verwaltung von Rollen, Aktivstatus und Passworten.</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary h-12" onClick={startCreate}><UserPlus2 className="h-4 w-4" /> Benutzer anlegen</button>
|
||||
</header>
|
||||
<section className="rounded-lg border border-border bg-surface p-4 shadow-soft">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<label className="flex h-12 items-center gap-2 rounded-lg border border-border px-3 md:col-span-2">
|
||||
<Search className="h-4 w-4 text-primary" />
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} className="flex-1 outline-none" placeholder="Suchen" />
|
||||
</label>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className="h-12 rounded-lg border border-border px-3">
|
||||
<option value="">Alle Rollen</option>
|
||||
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
<select value={active} onChange={(e) => setActive(e.target.value)} className="h-12 rounded-lg border border-border px-3">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="true">Aktiv</option>
|
||||
<option value="false">Inaktiv</option>
|
||||
</select>
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="h-12 rounded-lg border border-border px-3">
|
||||
<option value="created_at">Erstellt am</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="role">Rolle</option>
|
||||
<option value="last_login_at">Letzter Login</option>
|
||||
<option value="password_changed_at">Passwort geaendert</option>
|
||||
</select>
|
||||
<button type="button" className="btn btn-secondary h-12" onClick={() => setSortOrder((value) => (value === "asc" ? "desc" : "asc"))}>
|
||||
Sortierung: {sortOrder.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="overflow-hidden rounded-lg border border-border bg-surface shadow-soft">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-background text-xs uppercase text-text-light">
|
||||
<tr>
|
||||
<th className="px-5 py-4">Name</th>
|
||||
<th className="px-5 py-4">E-Mail</th>
|
||||
<th className="px-5 py-4">Rolle</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Letzter Login</th>
|
||||
<th className="px-5 py-4">Passwort zuletzt geaendert</th>
|
||||
<th className="px-5 py-4 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(users.data?.items ?? []).map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-5 py-4">{item.first_name} {item.last_name}</td>
|
||||
<td className="px-5 py-4">{item.email}</td>
|
||||
<td className="px-5 py-4">{roleLabels[item.role]}</td>
|
||||
<td className="px-5 py-4">{item.is_active ? "Aktiv" : "Inaktiv"}</td>
|
||||
<td className="px-5 py-4">{item.last_login_at ?? "nicht erfasst"}</td>
|
||||
<td className="px-5 py-4">{item.password_changed_at ?? "nicht erfasst"}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => startEdit(item)}>Bearbeiten</button>
|
||||
<button type="button" className="btn btn-secondary h-10 px-3" onClick={() => resetMutation.mutate(item)}>{resetMutation.isPending ? <span className="spinner" /> : <RotateCcw className="h-4 w-4" />} Temporäres Passwort setzen</button>
|
||||
{item.is_active ? (
|
||||
<button type="button" className="btn btn-danger h-10 px-3" onClick={() => deactivateMutation.mutate(item)}>Deaktivieren</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-success h-10 px-3" onClick={() => activateMutation.mutate(item)}><Check className="h-4 w-4" /> Reaktivieren</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="flex items-center justify-between text-sm text-text-light">
|
||||
<span>{users.data?.total ?? 0} Datensätze</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-secondary" disabled={page <= 1} onClick={() => setPage((v) => v - 1)}>Zurück</button>
|
||||
<span>Seite {page}</span>
|
||||
<button type="button" className="btn btn-secondary" disabled={(users.data?.items.length ?? 0) < 10} onClick={() => setPage((v) => v + 1)}>Weiter</button>
|
||||
</div>
|
||||
</footer>
|
||||
{temporaryPassword && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 shadow-soft"><p className="text-sm text-text-light">Temporäres Passwort</p><p className="mt-1 font-mono text-sm">{temporaryPassword}</p></div>}
|
||||
{message && <div className="fixed right-4 top-4 z-50 rounded-lg border border-success/30 bg-white p-4 text-sm text-success shadow-soft">{message}</div>}
|
||||
{error && <div className="fixed right-4 top-4 z-50 rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{error}</div>}
|
||||
<form onSubmit={form.handleSubmit((values) => saveMutation.mutate(values))} className="rounded-lg border border-border bg-surface p-5 shadow-soft">
|
||||
<h2 className="text-xl font-semibold">{editing ? "Benutzer bearbeiten" : "Benutzer anlegen"}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{(["first_name", "last_name", "email", "temporary_password"] as const).map((field) => (
|
||||
<label key={field} className="block">
|
||||
<span className="text-sm font-medium">{field === "first_name" ? "Vorname" : field === "last_name" ? "Nachname" : field === "email" ? "E-Mail" : "Temporäres Passwort"}</span>
|
||||
<input type={field === "email" ? "email" : "text"} {...form.register(field)} className="mt-2 h-12 w-full rounded-lg border border-border px-4" />
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Rolle</span>
|
||||
<select {...form.register("role")} className="mt-2 h-12 w-full rounded-lg border border-border px-4">
|
||||
{Object.entries(roleLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("is_active")} /> Aktiv</label>
|
||||
<label className="flex items-center gap-3 pt-7"><input type="checkbox" {...form.register("must_change_password")} /> Passwortwechsel erzwingen</label>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => form.reset()}>Zurücksetzen</button>
|
||||
<button type="submit" className="btn btn-primary">{saveMutation.isPending ? <span className="spinner" /> : null} Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@ import {
|
|||
LayoutDashboard,
|
||||
LogOut,
|
||||
MapPin,
|
||||
Shield,
|
||||
Stethoscope,
|
||||
UserRound
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "@/components/auth";
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
|
||||
|
|
@ -24,13 +26,21 @@ const navigation = [
|
|||
{ href: "/devices", label: "Geraete", icon: Stethoscope },
|
||||
{ href: "/equipment", label: "Pruefmittel", icon: Gauge },
|
||||
{ href: "/validations", label: "Validierungen", icon: ClipboardCheck },
|
||||
{ href: "/documents", label: "Dokumente", icon: FileArchive }
|
||||
{ href: "/documents", label: "Dokumente", icon: FileArchive },
|
||||
{ href: "/users", label: "Benutzer", icon: Shield, adminOnly: true }
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.user?.must_change_password && pathname !== "/profile/security") {
|
||||
router.push("/profile/security");
|
||||
}
|
||||
}, [auth.user?.must_change_password, pathname, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
|
||||
|
|
@ -40,6 +50,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||
<nav className="space-y-1">
|
||||
{navigation.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
if ("adminOnly" in item && item.adminOnly && auth.user?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { apiGet, User } from "@/lib/api";
|
||||
|
||||
type AuthContextValue = {
|
||||
token: string | null;
|
||||
setToken: (value: string | null) => void;
|
||||
logout: () => void;
|
||||
user: User | null;
|
||||
refreshUser: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
|
@ -14,11 +17,23 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
|||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [token, setTokenState] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTokenState(window.localStorage.getItem("atlas_token"));
|
||||
const stored = window.localStorage.getItem("atlas_token");
|
||||
setTokenState(stored);
|
||||
if (!stored) return;
|
||||
void apiGet<User>("/auth/me", stored).then(setUser).catch(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const setToken = (next: string | null) => {
|
||||
setTokenState(next);
|
||||
|
|
@ -34,9 +49,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
logout: () => {
|
||||
setToken(null);
|
||||
router.push("/login");
|
||||
},
|
||||
user,
|
||||
refreshUser: () => {
|
||||
if (token) {
|
||||
void apiGet<User>("/auth/me", token).then(setUser).catch(() => setUser(null));
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [router, token]);
|
||||
}, [router, token, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
|
@ -48,4 +69,3 @@ export function useAuth() {
|
|||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1";
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
export type Entity = {
|
||||
id: string;
|
||||
|
|
@ -61,6 +61,17 @@ export type Equipment = Entity & {
|
|||
status: "green" | "yellow" | "red";
|
||||
};
|
||||
|
||||
export type User = Entity & {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: "admin" | "pruefer" | "mitarbeiter" | "leser";
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
last_login_at?: string | null;
|
||||
password_changed_at?: string | null;
|
||||
};
|
||||
|
||||
export type ValidationItem = Entity & {
|
||||
report_number: string;
|
||||
customer_id: string;
|
||||
|
|
@ -154,3 +165,14 @@ export async function apiDelete(path: string, token: string): Promise<void> {
|
|||
throw new Error(`API request failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers ?? {}) }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue