Reorganize into monorepo layout, move mitarbeiter-app to legacy reference
Consolidates the previously separate omsorgapp and omsorgCore repos (each had their own nested .git with GitHub history) plus the old root-level website/mitarbeiter-app into a single monorepo, matching the structure already documented in the root CLAUDE.md. Also moves the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to serve as a template for a ground-up rewrite. Fixes .gitignore in the same pass: the config-secrets/uploads/data patterns were unanchored (relative to repo root, not depth-agnostic), so they silently stopped matching once the app moved under omsorgWeb/. Patterns are now **/-prefixed and cover both mitarbeiter-app and mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded employee documents out of version control. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8beb0fcf52
commit
b6c1389c55
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateEmployeeRequest(
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
DateOnly? EntryDate,
|
||||
DateOnly? ExitDate,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactPhone,
|
||||
string? EmergencyContactRelation,
|
||||
string? EmploymentType,
|
||||
string? Qualification);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateRoleRequest(string Name);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>Mode: "Invite" (Einladungsmail mit PIN, Mitarbeiter setzt eigenes Passwort) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).</summary>
|
||||
public record CreateUserRequest(
|
||||
Guid EmployeeId,
|
||||
string Username,
|
||||
Guid RoleId,
|
||||
string Mode,
|
||||
string? InitialPassword,
|
||||
int? PinValidityDays);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record EmployeeResponse(
|
||||
Guid Id,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Status,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
DateOnly? EntryDate,
|
||||
DateOnly? ExitDate,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactPhone,
|
||||
string? EmergencyContactRelation,
|
||||
string? EmploymentType,
|
||||
string? Qualification,
|
||||
string? AvatarFileName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ForgotPasswordRequestRequest(string Username);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>"sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung.</summary>
|
||||
public record ForgotPasswordRequestResponse(string Status);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ForgotPasswordResetRequest(string ResetToken, string NewPassword);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ForgotPasswordVerifyRequest(string Username, string Pin);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ForgotPasswordVerifyResponse(string ResetToken);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LogoutRequest(string RefreshToken);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record MeResponse(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string Role,
|
||||
IReadOnlyList<PermissionDto> Permissions,
|
||||
Guid? EmployeeId,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? Email,
|
||||
string? PhoneNumber,
|
||||
string? AvatarFileName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PagedResponse<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PasswordResetTemplateResponse(string Subject, string BodyTemplate);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PermissionDto(string Module, string Action);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>Mode: "Invite" (neuer Einladungs-PIN) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).</summary>
|
||||
public record ResetUserPasswordRequest(string Mode, string? InitialPassword, int? PinValidityDays);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RoleResponse(Guid Id, string Name);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record SendTestEmailRequest(string ToAddress, string? Subject, string? Body);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record SessionResponse(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateEmployeeRequest(
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Status,
|
||||
DateOnly? DateOfBirth,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
DateOnly? EntryDate,
|
||||
DateOnly? ExitDate,
|
||||
string? EmergencyContactName,
|
||||
string? EmergencyContactPhone,
|
||||
string? EmergencyContactRelation,
|
||||
string? EmploymentType,
|
||||
string? Qualification,
|
||||
string? AvatarFileName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateUserRequest(Guid RoleId, bool IsActive);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UserResponse(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Email;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Debug-/Admin-Funktion, um den konfigurierten Mail-Versand zu testen (siehe omsorgapp Debug-Seite).
|
||||
/// Sendet nur eine Testmail über den registrierten IEmailSender, mit vom Admin editierbarem Betreff/Text
|
||||
/// (vorbefüllt aus der echten Passwort-Reset-Vorlage) - SMTP-Zugangsdaten selbst verlassen den Server
|
||||
/// dabei zu keinem Zeitpunkt.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/admin/email")]
|
||||
public class AdminEmailController : ControllerBase
|
||||
{
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly EmailOptions _emailOptions;
|
||||
|
||||
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions)
|
||||
{
|
||||
_emailSender = emailSender;
|
||||
_emailOptions = emailOptions.Value;
|
||||
}
|
||||
|
||||
[HttpGet("password-reset-template")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public ActionResult<PasswordResetTemplateResponse> GetPasswordResetTemplate()
|
||||
{
|
||||
var template = _emailOptions.PasswordResetTemplate;
|
||||
return Ok(new PasswordResetTemplateResponse(template.Subject, template.BodyTemplate));
|
||||
}
|
||||
|
||||
[HttpPost("test-send")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> TestSend(SendTestEmailRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ToAddress))
|
||||
{
|
||||
return BadRequest(new { error = "to_address_required" });
|
||||
}
|
||||
|
||||
var template = _emailOptions.PasswordResetTemplate;
|
||||
var subject = string.IsNullOrWhiteSpace(request.Subject) ? template.Subject : request.Subject;
|
||||
var body = string.IsNullOrWhiteSpace(request.Body) ? template.BodyTemplate : request.Body;
|
||||
|
||||
// Testmail nutzt echte Platzhalter-Ersetzung (mit Beispiel-Werten), damit sie genau zeigt,
|
||||
// was ein Nutzer im echten Reset-Flow bekommen würde.
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["RESET_PIN"] = "123456",
|
||||
["RESET_PIN_EXPIRY_MINUTES"] = _emailOptions.PinExpiryMinutes.ToString()
|
||||
};
|
||||
subject = EmailTemplateRenderer.Render(subject, values);
|
||||
body = EmailTemplateRenderer.Render(body, values);
|
||||
|
||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Debug-/Admin-Sicht auf aktive Sessions (siehe omsorgapp Debug-Seite). Nur für Rollen mit
|
||||
/// UserManagement-Rechten (per Default: Administrator, siehe DbSeeder).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/admin/sessions")]
|
||||
public class AdminSessionsController : ControllerBase
|
||||
{
|
||||
private readonly ISessionAdminService _sessionAdminService;
|
||||
|
||||
public AdminSessionsController(ISessionAdminService sessionAdminService)
|
||||
{
|
||||
_sessionAdminService = sessionAdminService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<SessionResponse>>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var sessions = await _sessionAdminService.GetActiveSessionsAsync(cancellationToken);
|
||||
return Ok(sessions.Select(s => new SessionResponse(s.Id, s.Username, s.CreatedAt, s.ExpiresAt)));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/revoke")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Revoke(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeSessionAsync(id, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("revoke-all")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> RevokeAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
ICurrentUserService currentUserService,
|
||||
IPasswordResetService passwordResetService,
|
||||
IUserService userService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_authService = authService;
|
||||
_currentUserService = currentUserService;
|
||||
_passwordResetService = passwordResetService;
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult<LoginResponse>> Login(LoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
var result = await _authService.LoginAsync(request.Username, request.Password, ipAddress, cancellationToken);
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
|
||||
}
|
||||
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _authService.RefreshAsync(request.RefreshToken, cancellationToken);
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<MeResponse>> Me(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var permissions = profile.Permissions
|
||||
.Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString()))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
profile.UserId,
|
||||
profile.Username,
|
||||
profile.RoleName,
|
||||
permissions,
|
||||
profile.EmployeeId,
|
||||
profile.FirstName,
|
||||
profile.LastName,
|
||||
profile.Email,
|
||||
profile.PhoneNumber,
|
||||
profile.AvatarFileName));
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||
{
|
||||
return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password/request")]
|
||||
public async Task<ActionResult<ForgotPasswordRequestResponse>> ForgotPasswordRequest(
|
||||
ForgotPasswordRequestRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
|
||||
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
||||
}
|
||||
|
||||
var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
|
||||
return Ok(new ForgotPasswordRequestResponse(status));
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password/verify")]
|
||||
public async Task<ActionResult<ForgotPasswordVerifyResponse>> ForgotPasswordVerify(
|
||||
ForgotPasswordVerifyRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _passwordResetService.VerifyCodeAsync(request.Username, request.Pin, cancellationToken);
|
||||
if (!result.Success || result.ResetToken is null)
|
||||
{
|
||||
return Unauthorized(new { error = result.FailureReason ?? "invalid_or_expired" });
|
||||
}
|
||||
|
||||
return Ok(new ForgotPasswordVerifyResponse(result.ResetToken));
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password/reset")]
|
||||
public async Task<IActionResult> ForgotPasswordReset(
|
||||
ForgotPasswordResetRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var success = await _passwordResetService.ResetPasswordAsync(
|
||||
request.ResetToken, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/employees")]
|
||||
public class EmployeesController : ControllerBase
|
||||
{
|
||||
private static readonly string[] AllowedEmploymentTypes =
|
||||
{ "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" };
|
||||
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_employeeService = employeeService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<EmployeeResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? employmentType,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
{
|
||||
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.");
|
||||
}
|
||||
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _employeeService.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<EmployeeResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.View)]
|
||||
public async Task<ActionResult<EmployeeResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var employee = await _employeeService.GetByIdAsync(id, cancellationToken);
|
||||
return employee is null ? NotFound() : Ok(ToResponse(employee));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
|
||||
public async Task<ActionResult<EmployeeResponse>> Create(CreateEmployeeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
|
||||
{
|
||||
return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
|
||||
{
|
||||
return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.PhoneNumber is { Length: > 50 })
|
||||
{
|
||||
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.Email is { Length: > 200 })
|
||||
{
|
||||
return BadRequest("Email darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
|
||||
{
|
||||
return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
}
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
EntryDate = request.EntryDate,
|
||||
ExitDate = request.ExitDate,
|
||||
EmergencyContactName = request.EmergencyContactName,
|
||||
EmergencyContactPhone = request.EmergencyContactPhone,
|
||||
EmergencyContactRelation = request.EmergencyContactRelation,
|
||||
EmploymentType = request.EmploymentType,
|
||||
Qualification = request.Qualification
|
||||
};
|
||||
|
||||
var created = await _employeeService.CreateAsync(employee, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<EmployeeResponse>> Update(Guid id, UpdateEmployeeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
|
||||
{
|
||||
return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
|
||||
{
|
||||
return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50)
|
||||
{
|
||||
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.PhoneNumber is { Length: > 50 })
|
||||
{
|
||||
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.Email is { Length: > 200 })
|
||||
{
|
||||
return BadRequest("Email darf maximal 200 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
|
||||
{
|
||||
return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
}
|
||||
|
||||
var updates = new Employee
|
||||
{
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
Status = request.Status,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
EntryDate = request.EntryDate,
|
||||
ExitDate = request.ExitDate,
|
||||
EmergencyContactName = request.EmergencyContactName,
|
||||
EmergencyContactPhone = request.EmergencyContactPhone,
|
||||
EmergencyContactRelation = request.EmergencyContactRelation,
|
||||
EmploymentType = request.EmploymentType,
|
||||
Qualification = request.Qualification,
|
||||
AvatarFileName = request.AvatarFileName
|
||||
};
|
||||
|
||||
var updated = await _employeeService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
{
|
||||
return "Street darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (postalCode is { Length: > 10 })
|
||||
{
|
||||
return "PostalCode darf maximal 10 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (city is { Length: > 100 })
|
||||
{
|
||||
return "City darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (country is { Length: > 100 })
|
||||
{
|
||||
return "Country darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateStammdatenFields(
|
||||
string? emergencyContactName,
|
||||
string? emergencyContactPhone,
|
||||
string? emergencyContactRelation,
|
||||
string? employmentType,
|
||||
string? qualification)
|
||||
{
|
||||
if (emergencyContactName is { Length: > 200 })
|
||||
{
|
||||
return "EmergencyContactName darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (emergencyContactPhone is { Length: > 50 })
|
||||
{
|
||||
return "EmergencyContactPhone darf maximal 50 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (emergencyContactRelation is { Length: > 100 })
|
||||
{
|
||||
return "EmergencyContactRelation darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
{
|
||||
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.";
|
||||
}
|
||||
|
||||
if (qualification is { Length: > 500 })
|
||||
{
|
||||
return "Qualification darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EmployeeResponse ToResponse(Employee employee)
|
||||
=> new(
|
||||
employee.Id,
|
||||
employee.FirstName,
|
||||
employee.LastName,
|
||||
employee.Status,
|
||||
employee.DateOfBirth,
|
||||
employee.Street,
|
||||
employee.PostalCode,
|
||||
employee.City,
|
||||
employee.Country,
|
||||
employee.PhoneNumber,
|
||||
employee.Email,
|
||||
employee.EntryDate,
|
||||
employee.ExitDate,
|
||||
employee.EmergencyContactName,
|
||||
employee.EmergencyContactPhone,
|
||||
employee.EmergencyContactRelation,
|
||||
employee.EmploymentType,
|
||||
employee.Qualification,
|
||||
employee.AvatarFileName);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OmsorgCore.Infrastructure.Persistence;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly OmsorgCoreDbContext _db;
|
||||
|
||||
public HealthController(OmsorgCoreDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
bool databaseReachable;
|
||||
try
|
||||
{
|
||||
databaseReachable = await _db.Database.CanConnectAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
databaseReachable = false;
|
||||
}
|
||||
|
||||
return Ok(new { status = "ok", databaseReachable });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/roles")]
|
||||
public class RolesController : ControllerBase
|
||||
{
|
||||
private readonly IRoleService _roleService;
|
||||
|
||||
public RolesController(IRoleService roleService)
|
||||
{
|
||||
_roleService = roleService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<RoleResponse>>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = await _roleService.GetAllAsync(cancellationToken);
|
||||
return Ok(roles.Select(r => new RoleResponse(r.Id, r.Name)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
|
||||
public async Task<ActionResult<RoleResponse>> Create(CreateRoleRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var result = await _roleService.CreateAsync(request.Name, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
CreateRoleFailureReason.NameTaken => Conflict("Diese Rolle existiert bereits."),
|
||||
_ => BadRequest("Name ist erforderlich.")
|
||||
};
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/users")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private const int MinInitialPasswordLength = 8;
|
||||
private const int DefaultPinValidityDays = 7;
|
||||
private const int MinPinValidityDays = 1;
|
||||
private const int MaxPinValidityDays = 30;
|
||||
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await _userService.GetAllAsync(cancellationToken);
|
||||
return Ok(users.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
|
||||
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
|
||||
{
|
||||
return BadRequest("Username ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
||||
if (parseError is not null)
|
||||
{
|
||||
return BadRequest(parseError);
|
||||
}
|
||||
|
||||
var result = await _userService.CreateForEmployeeAsync(
|
||||
request.EmployeeId, request.Username, request.RoleId, mode,
|
||||
request.InitialPassword, pinValidity, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
CreateUserFailureReason.EmployeeNotFound => NotFound("Mitarbeiter nicht gefunden."),
|
||||
CreateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
|
||||
CreateUserFailureReason.EmployeeAlreadyHasAccount => Conflict("Dieser Mitarbeiter hat bereits ein Konto."),
|
||||
CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."),
|
||||
CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetAll), new UserResponse(
|
||||
result.UserId!.Value, request.Username, request.EmployeeId,
|
||||
result.RoleName!, IsActive: true, MustChangePassword: mode == UserCreationMode.Direct));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reset-password")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
||||
if (parseError is not null)
|
||||
{
|
||||
return BadRequest(parseError);
|
||||
}
|
||||
|
||||
var result = await _userService.AdminResetPasswordAsync(id, mode, request.InitialPassword, pinValidity, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateUserFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
UpdateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static (string? Error, UserCreationMode Mode, TimeSpan? PinValidity) ParseModeAndPinValidity(
|
||||
string modeRaw, int? pinValidityDays, string? initialPassword)
|
||||
{
|
||||
if (!Enum.TryParse<UserCreationMode>(modeRaw, ignoreCase: true, out var mode))
|
||||
{
|
||||
return ("Mode muss 'Invite' oder 'Direct' sein.", default, null);
|
||||
}
|
||||
|
||||
TimeSpan? pinValidity = null;
|
||||
if (mode == UserCreationMode.Invite)
|
||||
{
|
||||
var days = pinValidityDays ?? DefaultPinValidityDays;
|
||||
if (days < MinPinValidityDays || days > MaxPinValidityDays)
|
||||
{
|
||||
return ($"PinValidityDays muss zwischen {MinPinValidityDays} und {MaxPinValidityDays} liegen.", mode, null);
|
||||
}
|
||||
|
||||
pinValidity = TimeSpan.FromDays(days);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct
|
||||
&& (initialPassword is null || initialPassword.Length < MinInitialPasswordLength))
|
||||
{
|
||||
return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null);
|
||||
}
|
||||
|
||||
return (null, mode, pinValidity);
|
||||
}
|
||||
|
||||
private static UserResponse ToResponse(UserSummary summary)
|
||||
=> new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OmsorgCore.Application\OmsorgCore.Application.csproj" />
|
||||
<ProjectReference Include="..\OmsorgCore.Infrastructure\OmsorgCore.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\OmsorgCore.Engine\OmsorgCore.Engine.csproj" />
|
||||
<ProjectReference Include="..\OmsorgCore.Email\OmsorgCore.Email.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@OmsorgCore.Api_HostAddress = http://localhost:5245
|
||||
|
||||
GET {{OmsorgCore.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Email;
|
||||
using OmsorgCore.Engine;
|
||||
using OmsorgCore.Infrastructure;
|
||||
using OmsorgCore.Infrastructure.Persistence;
|
||||
using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "Bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
|
||||
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
|
||||
// auf denselben Daten, Api verdrahtet alles nur hier.
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddEngine();
|
||||
builder.Services.AddEmail(builder.Configuration);
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtOptions.Issuer,
|
||||
ValidAudience = jwtOptions.Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret))
|
||||
};
|
||||
|
||||
// Killswitch: ein JWT ist an sich zustandslos gültig bis zum Ablauf. Damit ein erzwungener
|
||||
// Logout (SessionAdminService) SOFORT wirkt und nicht erst nach bis zu 60 Minuten, wird bei
|
||||
// jedem Request der "sst"-Claim gegen den aktuellen User.SecurityStamp in der DB geprüft.
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
var userIdClaim = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var stampClaim = context.Principal?.FindFirstValue("sst");
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out var userId) || string.IsNullOrEmpty(stampClaim))
|
||||
{
|
||||
context.Fail("Token ohne gültige Benutzer-/Security-Stamp-Claims.");
|
||||
return;
|
||||
}
|
||||
|
||||
var userRepository = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
||||
var user = await userRepository.GetByIdAsync(userId);
|
||||
|
||||
if (user is null || !user.IsActive || user.SecurityStamp.ToString() != stampClaim)
|
||||
{
|
||||
context.Fail("Session wurde widerrufen.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Erzwingt einen Passwortwechsel serverseitig (nicht nur im UI versteckt) - solange
|
||||
// MustChangePassword gesetzt ist (Admin hat ein initiales Passwort direkt vergeben),
|
||||
// sind nur noch change-password/logout/refresh/me erlaubt. Frischer DB-Wert statt
|
||||
// Claim, damit ein erfolgreicher Wechsel sofort wirkt und nicht erst nach Token-Ablauf.
|
||||
if (user.MustChangePassword)
|
||||
{
|
||||
var path = context.HttpContext.Request.Path;
|
||||
var isAllowlisted = path.StartsWithSegments("/api/auth/change-password")
|
||||
|| path.StartsWithSegments("/api/auth/logout")
|
||||
|| path.StartsWithSegments("/api/auth/refresh")
|
||||
|| path.StartsWithSegments("/api/auth/me");
|
||||
|
||||
if (!isAllowlisted)
|
||||
{
|
||||
context.Fail("Passwort muss vor weiteren Aktionen geändert werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Wendet beim Start ausstehende Migrationen an - in jeder Umgebung, nicht nur Development,
|
||||
// damit ein Deploy/Update nie mit einem Schema laufen kann, das hinter dem Code zurückliegt.
|
||||
using (var migrationScope = app.Services.CreateScope())
|
||||
{
|
||||
var db = migrationScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
try
|
||||
{
|
||||
using var seedScope = app.Services.CreateScope();
|
||||
var db = seedScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
var passwordHasher = seedScope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||
var seedOptions = seedScope.ServiceProvider.GetRequiredService<IOptions<SeedOptions>>().Value;
|
||||
await DbSeeder.SeedDefaultAdminAsync(db, passwordHasher, seedOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Seed des Standard-Admin-Users fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
|
||||
}
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:35930",
|
||||
"sslPort": 44310
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5245",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "https://localhost:7268;http://localhost:5245",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Api.Security;
|
||||
|
||||
public class CurrentUserService : ICurrentUserService
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
public Guid? UserId
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
|
||||
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Erzwingt die Rechteprüfung auf Endpunkt-Ebene serverseitig, zusätzlich zur Prüfung auf
|
||||
/// Datenebene in den Application-Services (REQUIREMENTS.md NFR-9).
|
||||
/// Verwendung: [RequirePermission(ModuleType.Employees, PermissionAction.Create)]
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public class RequirePermissionAttribute : Attribute, IAsyncAuthorizationFilter
|
||||
{
|
||||
private readonly ModuleType _module;
|
||||
private readonly PermissionAction _action;
|
||||
|
||||
public RequirePermissionAttribute(ModuleType module, PermissionAction action)
|
||||
{
|
||||
_module = module;
|
||||
_action = action;
|
||||
}
|
||||
|
||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||
{
|
||||
var currentUser = context.HttpContext.RequestServices.GetRequiredService<ICurrentUserService>();
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||
{
|
||||
context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
var permissionService = context.HttpContext.RequestServices.GetRequiredService<IPermissionService>();
|
||||
var allowed = await permissionService.HasPermissionAsync(currentUser.UserId.Value, _module, _action);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
context.Result = new Microsoft.AspNetCore.Mvc.ForbidResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Jwt": {
|
||||
"Issuer": "OmsorgCore",
|
||||
"Audience": "OmsorgClients",
|
||||
"ExpiryMinutes": 60
|
||||
},
|
||||
"RefreshToken": {
|
||||
"ExpiryDays": 60
|
||||
},
|
||||
"Auth": {
|
||||
"MaxLoginFailures": 5,
|
||||
"LoginLockoutMinutes": 10
|
||||
},
|
||||
"Email": {
|
||||
"PinExpiryMinutes": 5,
|
||||
"MaxAttempts": 3,
|
||||
"ResetTokenExpiryMinutes": 10,
|
||||
"RequestCooldownSeconds": 60,
|
||||
"Provider": "Console",
|
||||
"PasswordResetAccount": "PasswordReset",
|
||||
"UserInviteAccount": "UserInvite",
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"EnableSsl": true
|
||||
},
|
||||
"Accounts": {
|
||||
"PasswordReset": {
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"SenderAddress": "",
|
||||
"SenderDisplayName": "OMSORG"
|
||||
},
|
||||
"UserInvite": {
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"SenderAddress": "",
|
||||
"SenderDisplayName": "OMSORG"
|
||||
}
|
||||
},
|
||||
"PasswordResetTemplate": {
|
||||
"Subject": "Ihr OMSORG Passwort-Reset-Code",
|
||||
"BodyTemplate": "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig."
|
||||
},
|
||||
"UserInviteTemplate": {
|
||||
"Subject": "Ihr OMSORG-Zugang",
|
||||
"BodyTemplate": "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="FromAccountKey"/> wählt einen der konfigurierten Mail-Accounts (Username/Passwort/
|
||||
/// Absenderadresse) aus, z. B. den dedizierten Passwort-Reset-Account - der SMTP-Server selbst ist
|
||||
/// gemeinsam konfiguriert, nur die Accounts unterscheiden sich. <c>null</c> = Default-Account.
|
||||
/// </summary>
|
||||
public record EmailMessage(string ToAddress, string Subject, string PlainTextBody, string? FromAccountKey = null);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Identität des aktuell angemeldeten Benutzers aus dem Request-Kontext.
|
||||
/// Implementierung lebt in der Api-Schicht (dort ist HttpContext bekannt), Application/Engine
|
||||
/// kennen nur dieses Interface.
|
||||
/// </summary>
|
||||
public interface ICurrentUserService
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IEmployeeRepository
|
||||
{
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IJwtTokenGenerator
|
||||
{
|
||||
(string Token, DateTime ExpiresAt) GenerateToken(User user);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface ILoginAttemptRepository
|
||||
{
|
||||
/// <summary>Anzahl fehlgeschlagener Versuche dieser IP seit <paramref name="since"/>.</summary>
|
||||
Task<int> CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Löscht alle bisherigen Fehlversuche dieser IP - nach einem erfolgreichen Login.</summary>
|
||||
Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>Konfigurierte Schwellwerte für die IP-basierte Login-Sperre (siehe AuthService.LoginAsync).</summary>
|
||||
public interface ILoginLockoutPolicy
|
||||
{
|
||||
int MaxFailures { get; }
|
||||
TimeSpan LockoutWindow { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string plainPassword);
|
||||
bool Verify(string plainPassword, string hash);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IPasswordResetCodeGenerator
|
||||
{
|
||||
/// <summary>Mindestabstand zwischen zwei Code-Anfragen desselben Users (minimaler Spam-Schutz).</summary>
|
||||
TimeSpan RequestCooldown { get; }
|
||||
|
||||
/// <summary>Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird.</summary>
|
||||
int MaxAttempts { get; }
|
||||
|
||||
/// <summary>Erzeugt einen neuen 6-stelligen PIN, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
|
||||
(string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin();
|
||||
|
||||
/// <summary>Wie <see cref="GeneratePin()"/>, aber mit explizit vorgegebener Gültigkeitsdauer statt der konfigurierten (z. B. für Account-Einladungen mit mehrtägiger Gültigkeit).</summary>
|
||||
(string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity);
|
||||
|
||||
/// <summary>Erzeugt einen neuen Reset-Token, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
|
||||
(string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken();
|
||||
|
||||
string HashPin(string rawPin);
|
||||
|
||||
string HashResetToken(string rawResetToken);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IPasswordResetCodeRepository
|
||||
{
|
||||
Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Aktivster (nicht widerrufener, nicht abgelaufener, noch nicht konsumierter, Versuche unter maxAttempts) Code eines Users, falls vorhanden.</summary>
|
||||
Task<PasswordResetCode?> GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Der jüngste Code eines Users unabhängig vom Status - für den Anfrage-Cooldown.</summary>
|
||||
Task<PasswordResetCode?> GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PasswordResetCode?> GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Entwertet alle noch aktiven Codes eines Users (z. B. bei einer neuen Anfrage - immer nur ein gültiger Code).</summary>
|
||||
Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die fertige Passwort-Reset-Mail (Betreff+Text) aus dem PIN. Implementierung liegt in
|
||||
/// OmsorgCore.Email (kennt die dortige Template-Konfiguration) - Engine kennt nur dieses Interface,
|
||||
/// damit die Abhängigkeitsrichtung (Engine -> Application, nicht -> Email) gewahrt bleibt.
|
||||
/// </summary>
|
||||
public interface IPasswordResetEmailComposer
|
||||
{
|
||||
EmailMessage Compose(string toEmail, string rawPin);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Zentrale Rechteprüfung: Rollen-Default kombiniert mit individuellen Overrides des Benutzers.
|
||||
/// Wird sowohl vom API-Authorization-Attribut als auch von Business-Services genutzt, damit
|
||||
/// Rechte auf Daten- UND Funktionsebene geprüft werden (REQUIREMENTS.md NFR-9).
|
||||
/// </summary>
|
||||
public interface IPermissionService
|
||||
{
|
||||
Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst).</summary>
|
||||
Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IRefreshTokenGenerator
|
||||
{
|
||||
/// <summary>Erzeugt einen neuen zufälligen Rohtoken, dessen Hash zur Speicherung und sein Ablaufdatum.</summary>
|
||||
(string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken();
|
||||
|
||||
/// <summary>Hasht einen vom Client vorgelegten Rohtoken zum Nachschlagen in der DB.</summary>
|
||||
string HashToken(string rawToken);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IRefreshTokenRepository
|
||||
{
|
||||
Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default);
|
||||
Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Für die Admin-Sessions-Ansicht - dort ist nur die Session-Id bekannt, nicht der Rohtoken.</summary>
|
||||
Task<RefreshToken?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Alle nicht widerrufenen, nicht abgelaufenen Refresh-Tokens (inkl. User) für die Sessions-Liste.</summary>
|
||||
Task<IReadOnlyList<RefreshToken>> GetAllActiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Alle aktiven Refresh-Tokens eines bestimmten Users (für die Ein-Session-Regel beim Login).</summary>
|
||||
Task<IReadOnlyList<RefreshToken>> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IRoleRepository
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Role?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Role role, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die fertige Einladungsmail (Betreff+Text) aus dem Invite-PIN. Implementierung liegt in
|
||||
/// OmsorgCore.Email (analog IPasswordResetEmailComposer) - Engine kennt nur dieses Interface.
|
||||
/// </summary>
|
||||
public interface IUserInviteEmailComposer
|
||||
{
|
||||
EmailMessage Compose(string toEmail, string rawPin, int validityDays);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me).</summary>
|
||||
Task<User?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.</summary>
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive Employee (für die E-Mail-Adresse) und Role (für den Rollennamen in Ergebnissen) - für Admin-Aktionen auf einem bestehenden User.</summary>
|
||||
Task<User?> GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<User?> GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(User user, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
|
||||
namespace OmsorgCore.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IPermissionService, PermissionService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
services.AddScoped<IRoleService, RoleService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).</summary>
|
||||
public record PermissionGrant(ModuleType Module, PermissionAction Action);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Profil des eingeloggten Users samt aufgelöster Rechte, für den `/api/auth/me`-Endpunkt. Trägt
|
||||
/// zusätzlich die verknüpften Employee-Basisdaten (falls vorhanden) - JWT und Login-Response tragen
|
||||
/// nur Username/Rolle, Clients brauchen für "angemeldet als ..."-Anzeigen aber Name/E-Mail/Avatar,
|
||||
/// ohne dafür einen separaten Employee-Request zu brauchen (siehe omsorgWeb/mitarbeiter-app).
|
||||
/// </summary>
|
||||
public record UserProfile(
|
||||
Guid UserId,
|
||||
string Username,
|
||||
string RoleName,
|
||||
IReadOnlyList<PermissionGrant> Permissions,
|
||||
Guid? EmployeeId,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? Email,
|
||||
string? PhoneNumber,
|
||||
string? AvatarFileName);
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OmsorgCore.Domain\OmsorgCore.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum AdminResetPasswordFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
EmployeeEmailMissing,
|
||||
InitialPasswordRequired
|
||||
}
|
||||
|
||||
public class AdminResetPasswordResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public AdminResetPasswordFailureReason? FailureReason { get; init; }
|
||||
|
||||
/// <summary>Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.</summary>
|
||||
public string? Email { get; init; }
|
||||
public string? RawInvitePin { get; init; }
|
||||
|
||||
public static AdminResetPasswordResult Fail(AdminResetPasswordFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static AdminResetPasswordResult Ok(string? email = null, string? rawInvitePin = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Email = email,
|
||||
RawInvitePin = rawInvitePin
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AuthResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? RefreshToken { get; init; }
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
public bool MustChangePassword { get; init; }
|
||||
public bool IsLockedOut { get; init; }
|
||||
|
||||
public static AuthResult Fail() => new() { Success = false };
|
||||
|
||||
public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true };
|
||||
|
||||
public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
|
||||
{
|
||||
Success = true,
|
||||
Token = token,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expiresAt,
|
||||
MustChangePassword = mustChangePassword
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IJwtTokenGenerator _tokenGenerator;
|
||||
private readonly IRefreshTokenGenerator _refreshTokenGenerator;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ILoginAttemptRepository _loginAttemptRepository;
|
||||
private readonly ILoginLockoutPolicy _loginLockoutPolicy;
|
||||
|
||||
public AuthService(
|
||||
IUserRepository userRepository,
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordHasher passwordHasher,
|
||||
IJwtTokenGenerator tokenGenerator,
|
||||
IRefreshTokenGenerator refreshTokenGenerator,
|
||||
IPermissionService permissionService,
|
||||
ILoginAttemptRepository loginAttemptRepository,
|
||||
ILoginLockoutPolicy loginLockoutPolicy)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_tokenGenerator = tokenGenerator;
|
||||
_refreshTokenGenerator = refreshTokenGenerator;
|
||||
_permissionService = permissionService;
|
||||
_loginAttemptRepository = loginAttemptRepository;
|
||||
_loginLockoutPolicy = loginLockoutPolicy;
|
||||
}
|
||||
|
||||
public async Task<AuthResult> LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var lockoutSince = DateTime.UtcNow - _loginLockoutPolicy.LockoutWindow;
|
||||
var recentFailures = await _loginAttemptRepository.CountRecentFailuresAsync(ipAddress, lockoutSince, cancellationToken);
|
||||
if (recentFailures >= _loginLockoutPolicy.MaxFailures)
|
||||
{
|
||||
return AuthResult.LockedOut();
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
|
||||
if (user is null || !user.IsActive || !_passwordHasher.Verify(password, user.PasswordHash))
|
||||
{
|
||||
await _loginAttemptRepository.AddAsync(new LoginAttempt
|
||||
{
|
||||
IpAddress = ipAddress,
|
||||
Username = username,
|
||||
Succeeded = false
|
||||
}, cancellationToken);
|
||||
await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return AuthResult.Fail();
|
||||
}
|
||||
|
||||
await _loginAttemptRepository.ClearFailuresAsync(ipAddress, cancellationToken);
|
||||
await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await EndOtherSessionsAsync(user, cancellationToken);
|
||||
|
||||
return await IssueTokenPairAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
// Erzwingt "ein User = eine aktive Session": widerruft alle bisherigen Refresh-Tokens dieses
|
||||
// Users und würfelt den SecurityStamp neu, damit auch bereits ausgestellte Access-Tokens
|
||||
// anderer Sessions sofort ungültig werden (derselbe Killswitch-Mechanismus wie SessionAdminService).
|
||||
private async Task EndOtherSessionsAsync(User user, CancellationToken cancellationToken)
|
||||
{
|
||||
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(user.Id, cancellationToken);
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
user.SecurityStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
public async Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
|
||||
var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
if (existing is null || !existing.IsActive)
|
||||
{
|
||||
return AuthResult.Fail();
|
||||
}
|
||||
|
||||
existing.RevokedAt = DateTime.UtcNow;
|
||||
|
||||
return await IssueTokenPairAsync(existing.User, cancellationToken, replaces: existing);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
|
||||
var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
|
||||
if (existing is null || existing.RevokedAt is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
existing.RevokedAt = DateTime.UtcNow;
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var permissions = await _permissionService.GetGrantedPermissionsAsync(userId, cancellationToken);
|
||||
return new UserProfile(
|
||||
user.Id,
|
||||
user.Username,
|
||||
user.Role.Name,
|
||||
permissions,
|
||||
user.EmployeeId,
|
||||
user.Employee?.FirstName,
|
||||
user.Employee?.LastName,
|
||||
user.Employee?.Email,
|
||||
user.Employee?.PhoneNumber,
|
||||
user.Employee?.AvatarFileName);
|
||||
}
|
||||
|
||||
private async Task<AuthResult> IssueTokenPairAsync(User user, CancellationToken cancellationToken, RefreshToken? replaces = null)
|
||||
{
|
||||
var (accessToken, accessTokenExpiresAt) = _tokenGenerator.GenerateToken(user);
|
||||
var (rawRefreshToken, refreshTokenHash, refreshTokenExpiresAt) = _refreshTokenGenerator.GenerateToken();
|
||||
|
||||
var newToken = new RefreshToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
User = user,
|
||||
TokenHash = refreshTokenHash,
|
||||
ExpiresAt = refreshTokenExpiresAt
|
||||
};
|
||||
await _refreshTokenRepository.AddAsync(newToken, cancellationToken);
|
||||
|
||||
if (replaces is not null)
|
||||
{
|
||||
replaces.ReplacedByTokenId = newToken.Id;
|
||||
}
|
||||
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum CreateRoleFailureReason
|
||||
{
|
||||
NameRequired,
|
||||
NameTaken
|
||||
}
|
||||
|
||||
public class CreateRoleResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public CreateRoleFailureReason? FailureReason { get; init; }
|
||||
public Role? Role { get; init; }
|
||||
|
||||
public static CreateRoleResult Fail(CreateRoleFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static CreateRoleResult Ok(Role role) => new()
|
||||
{
|
||||
Success = true,
|
||||
Role = role
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum CreateUserFailureReason
|
||||
{
|
||||
EmployeeNotFound,
|
||||
EmployeeAlreadyHasAccount,
|
||||
EmployeeEmailMissing,
|
||||
UsernameTaken,
|
||||
RoleNotFound,
|
||||
InitialPasswordRequired
|
||||
}
|
||||
|
||||
public class CreateUserResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public CreateUserFailureReason? FailureReason { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public string? RoleName { get; init; }
|
||||
|
||||
/// <summary>Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.</summary>
|
||||
public string? Email { get; init; }
|
||||
public string? RawInvitePin { get; init; }
|
||||
|
||||
public static CreateUserResult Fail(CreateUserFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static CreateUserResult Ok(Guid userId, string roleName, string? email = null, string? rawInvitePin = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
UserId = userId,
|
||||
RoleName = roleName,
|
||||
Email = email,
|
||||
RawInvitePin = rawInvitePin
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class EmployeeService : IEmployeeService
|
||||
{
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
|
||||
public EmployeeService(IEmployeeRepository employeeRepository)
|
||||
{
|
||||
_employeeRepository = employeeRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _employeeRepository.AddAsync(employee, cancellationToken);
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
return employee;
|
||||
}
|
||||
|
||||
public async Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
employee.FirstName = updates.FirstName;
|
||||
employee.LastName = updates.LastName;
|
||||
employee.DateOfBirth = updates.DateOfBirth;
|
||||
employee.Street = updates.Street;
|
||||
employee.PostalCode = updates.PostalCode;
|
||||
employee.City = updates.City;
|
||||
employee.Country = updates.Country;
|
||||
employee.PhoneNumber = updates.PhoneNumber;
|
||||
employee.Email = updates.Email;
|
||||
employee.EntryDate = updates.EntryDate;
|
||||
employee.ExitDate = updates.ExitDate;
|
||||
employee.Status = updates.Status;
|
||||
employee.EmergencyContactName = updates.EmergencyContactName;
|
||||
employee.EmergencyContactPhone = updates.EmergencyContactPhone;
|
||||
employee.EmergencyContactRelation = updates.EmergencyContactRelation;
|
||||
employee.EmploymentType = updates.EmploymentType;
|
||||
employee.Qualification = updates.Qualification;
|
||||
employee.AvatarFileName = updates.AvatarFileName;
|
||||
employee.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _employeeRepository.UpdateAsync(employee, cancellationToken);
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
return employee;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthResult> LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Löst den vorgelegten Refresh-Token ein: widerruft ihn und stellt ein neues Token-Paar aus (Rotation).</summary>
|
||||
Task<AuthResult> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Widerruft einen Refresh-Token (Logout). Idempotent, kein Fehler bei unbekanntem/bereits widerrufenem Token.</summary>
|
||||
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Profil (Username, Rolle, aufgelöste Rechte) des eingeloggten Users, für `/api/auth/me`.</summary>
|
||||
Task<UserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IEmployeeService
|
||||
{
|
||||
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IPasswordResetService
|
||||
{
|
||||
Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users
|
||||
/// selbst) - läuft durch dieselbe PasswordResetCode-Tabelle wie RequestResetAsync, aber ohne Cooldown-Prüfung
|
||||
/// und mit einer explizit vorgegebenen, i. d. R. mehrtägigen Gültigkeit statt der kurzen Reset-PIN-Gültigkeit.
|
||||
/// </summary>
|
||||
Task<string> IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface ISessionAdminService
|
||||
{
|
||||
Task<IReadOnlyList<SessionInfo>> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Widerruft genau diese Session und erzwingt für ihren Nutzer sofort einen neuen Login.</summary>
|
||||
Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Widerruft alle aktiven Sessions und erzwingt für ALLE Nutzer sofort einen neuen Login.</summary>
|
||||
Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CreateUserResult> CreateForEmployeeAsync(
|
||||
Guid employeeId,
|
||||
string username,
|
||||
Guid roleId,
|
||||
UserCreationMode mode,
|
||||
string? initialPassword,
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Admin setzt das Passwort eines bestehenden Users zurück (Invite-PIN oder direktes Passwort) - spiegelt CreateForEmployeeAsync, aber ohne Neuanlage.</summary>
|
||||
Task<AdminResetPasswordResult> AdminResetPasswordAsync(
|
||||
Guid userId,
|
||||
UserCreationMode mode,
|
||||
string? initialPassword,
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Rolle und/oder Aktiv-Status ändern. Deaktivieren beendet sofort alle Sessions (kein Hard-Delete).</summary>
|
||||
Task<UpdateUserResult> UpdateAsync(
|
||||
Guid userId,
|
||||
Guid roleId,
|
||||
bool isActive,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum PasswordResetRequestStatus
|
||||
{
|
||||
Sent,
|
||||
CannotReset
|
||||
}
|
||||
|
||||
public class PasswordResetRequestResult
|
||||
{
|
||||
public PasswordResetRequestStatus Status { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>Nur gesetzt, wenn tatsächlich ein neuer Code erzeugt wurde und dispatcht werden soll.</summary>
|
||||
public string? RawPin { get; init; }
|
||||
|
||||
public static PasswordResetRequestResult CannotReset() => new() { Status = PasswordResetRequestStatus.CannotReset };
|
||||
|
||||
public static PasswordResetRequestResult Sent(Guid userId, string email, string? rawPin) => new()
|
||||
{
|
||||
Status = PasswordResetRequestStatus.Sent,
|
||||
UserId = userId,
|
||||
Email = email,
|
||||
RawPin = rawPin
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class PasswordResetService : IPasswordResetService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IPasswordResetCodeRepository _codeRepository;
|
||||
private readonly IPasswordResetCodeGenerator _codeGenerator;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public PasswordResetService(
|
||||
IUserRepository userRepository,
|
||||
IPasswordResetCodeRepository codeRepository,
|
||||
IPasswordResetCodeGenerator codeGenerator,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_codeRepository = codeRepository;
|
||||
_codeGenerator = codeGenerator;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
|
||||
var email = user?.Employee?.Email;
|
||||
if (user is null || !user.IsActive || string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return PasswordResetRequestResult.CannotReset();
|
||||
}
|
||||
|
||||
var latest = await _codeRepository.GetLatestByUserIdAsync(user.Id, cancellationToken);
|
||||
if (latest is not null && DateTime.UtcNow - latest.CreatedAt < _codeGenerator.RequestCooldown)
|
||||
{
|
||||
// Cooldown aktiv: kein neuer Code, kein erneuter Versand, aber nach außen unauffällig -
|
||||
// der Client bekommt weiterhin "Sent" ohne dass tatsächlich etwas verschickt wird.
|
||||
return PasswordResetRequestResult.Sent(user.Id, email, rawPin: null);
|
||||
}
|
||||
|
||||
await _codeRepository.InvalidateActiveForUserAsync(user.Id, cancellationToken);
|
||||
|
||||
var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin();
|
||||
await _codeRepository.AddAsync(new PasswordResetCode
|
||||
{
|
||||
UserId = user.Id,
|
||||
User = user,
|
||||
CodeHash = pinHash,
|
||||
ExpiresAt = expiresAt
|
||||
}, cancellationToken);
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PasswordResetRequestResult.Sent(user.Id, email, rawPin);
|
||||
}
|
||||
|
||||
public async Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return PasswordResetVerifyResult.Fail("invalid_or_expired");
|
||||
}
|
||||
|
||||
var code = await _codeRepository.GetActiveByUserIdAsync(user.Id, _codeGenerator.MaxAttempts, cancellationToken);
|
||||
if (code is null)
|
||||
{
|
||||
return PasswordResetVerifyResult.Fail("invalid_or_expired");
|
||||
}
|
||||
|
||||
var pinHash = _codeGenerator.HashPin(pin);
|
||||
if (code.CodeHash != pinHash)
|
||||
{
|
||||
code.AttemptCount++;
|
||||
if (code.AttemptCount >= _codeGenerator.MaxAttempts)
|
||||
{
|
||||
code.InvalidatedAt = DateTime.UtcNow;
|
||||
}
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return code.AttemptCount >= _codeGenerator.MaxAttempts
|
||||
? PasswordResetVerifyResult.Fail("too_many_attempts")
|
||||
: PasswordResetVerifyResult.Fail("invalid_or_expired");
|
||||
}
|
||||
|
||||
code.ConsumedAt = DateTime.UtcNow;
|
||||
var (rawResetToken, resetTokenHash, resetExpiresAt) = _codeGenerator.GenerateResetToken();
|
||||
code.ResetTokenHash = resetTokenHash;
|
||||
code.ResetTokenExpiresAt = resetExpiresAt;
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return PasswordResetVerifyResult.Ok(rawResetToken);
|
||||
}
|
||||
|
||||
public async Task<string> IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _codeRepository.InvalidateActiveForUserAsync(userId, cancellationToken);
|
||||
|
||||
var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin(validity);
|
||||
await _codeRepository.AddAsync(new PasswordResetCode
|
||||
{
|
||||
UserId = userId,
|
||||
CodeHash = pinHash,
|
||||
ExpiresAt = expiresAt
|
||||
}, cancellationToken);
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return rawPin;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resetTokenHash = _codeGenerator.HashResetToken(resetToken);
|
||||
var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken);
|
||||
if (code is null || !code.IsResetTokenActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
code.ResetTokenUsedAt = DateTime.UtcNow;
|
||||
code.User.PasswordHash = _passwordHasher.Hash(newPassword);
|
||||
|
||||
// Wie nach einem normalen Login: alle bestehenden Sessions beenden, SecurityStamp erneuern -
|
||||
// ein Passwort-Reset darf alte, evtl. kompromittierte Sessions nicht überleben lassen.
|
||||
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(code.UserId, cancellationToken);
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
code.User.SecurityStamp = Guid.NewGuid();
|
||||
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class PasswordResetVerifyResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ResetToken { get; init; }
|
||||
|
||||
/// <summary>"invalid_or_expired" | "too_many_attempts"</summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
public static PasswordResetVerifyResult Fail(string reason) => new() { Success = false, FailureReason = reason };
|
||||
|
||||
public static PasswordResetVerifyResult Ok(string resetToken) => new() { Success = true, ResetToken = resetToken };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5:
|
||||
/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer.
|
||||
/// </summary>
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public PermissionService(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsGranted(user, module, action);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return Array.Empty<PermissionGrant>();
|
||||
}
|
||||
|
||||
var grants = new List<PermissionGrant>();
|
||||
foreach (ModuleType module in Enum.GetValues<ModuleType>())
|
||||
{
|
||||
foreach (PermissionAction action in Enum.GetValues<PermissionAction>())
|
||||
{
|
||||
if (IsGranted(user, module, action))
|
||||
{
|
||||
grants.Add(new PermissionGrant(module, action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grants;
|
||||
}
|
||||
|
||||
private static bool IsGranted(User user, ModuleType module, PermissionAction action)
|
||||
{
|
||||
UserPermissionOverride? override_ = user.PermissionOverrides
|
||||
.FirstOrDefault(o => o.Module == module && o.Action == action);
|
||||
|
||||
if (override_ is not null)
|
||||
{
|
||||
return override_.Effect == PermissionEffect.Grant;
|
||||
}
|
||||
|
||||
return user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class RoleService : IRoleService
|
||||
{
|
||||
private readonly IRoleRepository _roleRepository;
|
||||
|
||||
public RoleService(IRoleRepository roleRepository)
|
||||
{
|
||||
_roleRepository = roleRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _roleRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public async Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return CreateRoleResult.Fail(CreateRoleFailureReason.NameRequired);
|
||||
}
|
||||
|
||||
if (await _roleRepository.ExistsByNameAsync(name, cancellationToken))
|
||||
{
|
||||
return CreateRoleResult.Fail(CreateRoleFailureReason.NameTaken);
|
||||
}
|
||||
|
||||
var role = new Role { Name = name };
|
||||
await _roleRepository.AddAsync(role, cancellationToken);
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return CreateRoleResult.Ok(role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class SessionAdminService : ISessionAdminService
|
||||
{
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public SessionAdminService(IRefreshTokenRepository refreshTokenRepository, IUserRepository userRepository)
|
||||
{
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionInfo>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
|
||||
return tokens
|
||||
.Select(t => new SessionInfo(t.Id, t.User.Username, t.CreatedAt, t.ExpiresAt))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await _refreshTokenRepository.GetByIdAsync(sessionId, cancellationToken);
|
||||
if (token is null || token.RevokedAt is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
token.User.SecurityStamp = Guid.NewGuid();
|
||||
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var activeTokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var users = await _userRepository.GetAllAsync(cancellationToken);
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.SecurityStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public record SessionInfo(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateUserFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
RoleNotFound
|
||||
}
|
||||
|
||||
public class UpdateUserResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateUserFailureReason? FailureReason { get; init; }
|
||||
|
||||
public static UpdateUserResult Fail(UpdateUserFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateUserResult Ok() => new() { Success = true };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UserCreationMode
|
||||
{
|
||||
/// <summary>Mitarbeiter bekommt eine Einladungsmail mit PIN und setzt sein Passwort selbst - der Admin kennt es nie.</summary>
|
||||
Invite,
|
||||
|
||||
/// <summary>Admin vergibt ein initiales Passwort direkt (z. B. ohne hinterlegte E-Mail) - MustChangePassword erzwingt einen Wechsel beim ersten Login.</summary>
|
||||
Direct
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class UserService : IUserService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
private readonly IRoleRepository _roleRepository;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
IEmployeeRepository employeeRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher passwordHasher,
|
||||
IPasswordResetService passwordResetService,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_employeeRepository = employeeRepository;
|
||||
_roleRepository = roleRepository;
|
||||
_passwordHasher = passwordHasher;
|
||||
_passwordResetService = passwordResetService;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var users = await _userRepository.GetAllAsync(cancellationToken);
|
||||
return users
|
||||
.Select(u => new UserSummary(u.Id, u.Username, u.EmployeeId, u.Role.Name, u.IsActive, u.MustChangePassword))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<CreateUserResult> CreateForEmployeeAsync(
|
||||
Guid employeeId,
|
||||
string username,
|
||||
Guid roleId,
|
||||
UserCreationMode mode,
|
||||
string? initialPassword,
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeNotFound);
|
||||
}
|
||||
|
||||
var existingForEmployee = await _userRepository.GetByEmployeeIdAsync(employeeId, cancellationToken);
|
||||
if (existingForEmployee is not null)
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeAlreadyHasAccount);
|
||||
}
|
||||
|
||||
if (await _userRepository.ExistsByUsernameAsync(username, cancellationToken))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.UsernameTaken);
|
||||
}
|
||||
|
||||
var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.RoleNotFound);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing);
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
RoleId = roleId,
|
||||
EmployeeId = employeeId,
|
||||
// Invite-Modus: kein für den Login nutzbares Passwort - Hash eines Zufallswerts, den niemand
|
||||
// je eingibt. Der Mitarbeiter setzt sein eigenes Passwort über den unten ausgestellten PIN-Flow.
|
||||
PasswordHash = mode == UserCreationMode.Direct
|
||||
? _passwordHasher.Hash(initialPassword!)
|
||||
: _passwordHasher.Hash(Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N")),
|
||||
MustChangePassword = mode == UserCreationMode.Direct
|
||||
};
|
||||
|
||||
await _userRepository.AddAsync(user, cancellationToken);
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (mode == UserCreationMode.Invite)
|
||||
{
|
||||
var validity = invitePinValidity ?? TimeSpan.FromDays(7);
|
||||
var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
|
||||
return CreateUserResult.Ok(user.Id, role.Name, employee.Email, rawPin);
|
||||
}
|
||||
|
||||
return CreateUserResult.Ok(user.Id, role.Name);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordHasher.Hash(newPassword);
|
||||
user.MustChangePassword = false;
|
||||
|
||||
// Wie nach einem Passwort-Reset: bestehende Sessions beenden, SecurityStamp erneuern - der
|
||||
// gerade benutzte Access-Token wird dadurch ab dem nächsten Request ungültig, ein neuer Login
|
||||
// mit dem neuen Passwort ist danach nötig (siehe PasswordResetService.ResetPasswordAsync).
|
||||
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
user.SecurityStamp = Guid.NewGuid();
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<AdminResetPasswordResult> AdminResetPasswordAsync(
|
||||
Guid userId,
|
||||
UserCreationMode mode,
|
||||
string? initialPassword,
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithEmployeeAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct)
|
||||
{
|
||||
user.PasswordHash = _passwordHasher.Hash(initialPassword!);
|
||||
user.MustChangePassword = true;
|
||||
|
||||
// Admin-gesetztes Passwort ersetzt sofort alle laufenden Sessions - wie ein regulärer
|
||||
// Passwort-Reset (siehe PasswordResetService.ResetPasswordAsync).
|
||||
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
user.SecurityStamp = Guid.NewGuid();
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return AdminResetPasswordResult.Ok();
|
||||
}
|
||||
|
||||
var validity = invitePinValidity ?? TimeSpan.FromDays(7);
|
||||
var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
|
||||
return AdminResetPasswordResult.Ok(user.Employee!.Email, rawPin);
|
||||
}
|
||||
|
||||
public async Task<UpdateUserResult> UpdateAsync(
|
||||
Guid userId,
|
||||
Guid roleId,
|
||||
bool isActive,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return UpdateUserResult.Fail(UpdateUserFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return UpdateUserResult.Fail(UpdateUserFailureReason.RoleNotFound);
|
||||
}
|
||||
|
||||
user.RoleId = roleId;
|
||||
|
||||
var wasActive = user.IsActive;
|
||||
user.IsActive = isActive;
|
||||
|
||||
if (wasActive && !isActive)
|
||||
{
|
||||
// Sofortiger Killswitch bei Deaktivierung - kein Warten auf Token-Ablauf (siehe SessionAdminService).
|
||||
var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
}
|
||||
user.SecurityStamp = Guid.NewGuid();
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateUserResult.Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
/// <summary>Für die Account-Übersicht (Mitarbeiter mit/ohne Login-Konto) - siehe UsersController.</summary>
|
||||
public record UserSummary(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Domain.Common;
|
||||
|
||||
public abstract class AuditableEntity : Entity
|
||||
{
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
// Soft-Delete statt Hard-Delete für geschäftsrelevante Daten (siehe CLAUDE.md).
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Domain.Common;
|
||||
|
||||
public abstract class Entity
|
||||
{
|
||||
public Guid Id { get; protected set; } = Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Vertrag" — verbindliche Vereinbarung mit Mitarbeiter oder Einrichtung
|
||||
/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.3).
|
||||
/// </summary>
|
||||
public class Contract : AuditableEntity
|
||||
{
|
||||
public string ContractType { get; set; } = string.Empty;
|
||||
|
||||
public Guid? EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public Guid? FacilityId { get; set; }
|
||||
public Facility? Facility { get; set; }
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string Status { get; set; } = "Entwurf";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Mitarbeiter" — digitale Personalakte (REQUIREMENTS.md Abschnitt 6, Blueprint 19.1).
|
||||
/// Kernfelder für das Fundament; Ausbau (Qualifikationen, Fortbildungen, Dokumente, ...) folgt in
|
||||
/// späteren Schritten.
|
||||
/// </summary>
|
||||
public class Employee : AuditableEntity
|
||||
{
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public string? Street { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public DateOnly? EntryDate { get; set; }
|
||||
public DateOnly? ExitDate { get; set; }
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
public string? EmergencyContactName { get; set; }
|
||||
public string? EmergencyContactPhone { get; set; }
|
||||
public string? EmergencyContactRelation { get; set; }
|
||||
public string? EmploymentType { get; set; }
|
||||
public string? Qualification { get; set; }
|
||||
|
||||
/// <summary>Dateiname des Profilbilds - die Datei selbst liegt weiterhin lokal beim jeweiligen Client (z. B. omsorgWeb/assets/avatars/), hier nur die Zuordnung.</summary>
|
||||
public string? AvatarFileName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2).
|
||||
/// </summary>
|
||||
public class Facility : AuditableEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? FacilityType { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? BillingAddress { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string CrmStatus { get; set; } = "Lead";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Rechnung" — entsteht ausschließlich aus freigegebener Zeiterfassung + Konditionen
|
||||
/// der Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.6, Regel FR-RE-1/FR-ZE-3).
|
||||
/// </summary>
|
||||
public class Invoice : AuditableEntity
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public DateOnly BillingPeriodStart { get; set; }
|
||||
public DateOnly BillingPeriodEnd { get; set; }
|
||||
|
||||
public decimal NetAmount { get; set; }
|
||||
public decimal GrossAmount { get; set; }
|
||||
|
||||
public string Status { get; set; } = "Entwurf";
|
||||
public DateOnly? DueDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Protokolliert jeden Login-Versuch (Erfolg und Fehlschlag) IP-basiert - Grundlage für die
|
||||
/// zentrale Brute-Force-Sperre in <see cref="OmsorgCore.Application.Services.AuthService"/>.
|
||||
/// </summary>
|
||||
public class LoginAttempt : Entity
|
||||
{
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public bool Succeeded { get; set; }
|
||||
public DateTime AttemptedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Auftrag" — Personalbedarf einer Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.4).
|
||||
/// </summary>
|
||||
public class Order : AuditableEntity
|
||||
{
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string? RequiredQualification { get; set; }
|
||||
public string Status { get; set; } = "Anfrage";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Einmal-PIN für den "Passwort vergessen"-Flow. Es wird nie der rohe 6-stellige PIN gespeichert,
|
||||
/// nur sein SHA-256-Hash (analog RefreshToken.TokenHash). Nach erfolgreicher Verifikation wird ein
|
||||
/// separater, ebenfalls nur gehashter Reset-Token ausgestellt (ResetTokenHash), der für den
|
||||
/// abschließenden Passwort-Set-Aufruf gebraucht wird - der PIN selbst wird nie zweimal als
|
||||
/// Credential verwendet (siehe RefreshToken-Rotationsmuster).
|
||||
/// </summary>
|
||||
public class PasswordResetCode : Entity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public string CodeHash { get; set; } = string.Empty;
|
||||
public int AttemptCount { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
public DateTime? InvalidatedAt { get; set; }
|
||||
|
||||
/// <summary>Nur gesetzt, nachdem der PIN erfolgreich verifiziert wurde (ConsumedAt != null).</summary>
|
||||
public string? ResetTokenHash { get; set; }
|
||||
public DateTime? ResetTokenExpiresAt { get; set; }
|
||||
public DateTime? ResetTokenUsedAt { get; set; }
|
||||
|
||||
public bool IsActive => InvalidatedAt is null && ConsumedAt is null
|
||||
&& DateTime.UtcNow < ExpiresAt;
|
||||
|
||||
public bool IsResetTokenActive => ResetTokenHash is not null && ResetTokenUsedAt is null
|
||||
&& ResetTokenExpiresAt is not null && DateTime.UtcNow < ResetTokenExpiresAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Langlebiges Gegenstück zum kurzlebigen JWT-Access-Token. Rotiert bei jeder Nutzung
|
||||
/// (<see cref="ReplacedByTokenId"/>), damit ein Nutzer nicht täglich neu einloggen muss.
|
||||
/// Es wird nie der Rohtoken gespeichert, nur sein Hash.
|
||||
/// </summary>
|
||||
public class RefreshToken : Entity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public string TokenHash { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public Guid? ReplacedByTokenId { get; set; }
|
||||
|
||||
public bool IsActive => RevokedAt is null && DateTime.UtcNow < ExpiresAt;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Rolle als Rechte-Vorlage (z. B. Geschäftsführung, Disposition, Recruiting, Außendienst).
|
||||
/// Siehe REQUIREMENTS.md Abschnitt 3 und 7.
|
||||
/// </summary>
|
||||
public class Role : Entity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<RolePermission> RolePermissions { get; set; } = new List<RolePermission>();
|
||||
public ICollection<User> Users { get; set; } = new List<User>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Standard-Recht einer Rolle für ein Modul/eine Aktion.
|
||||
/// </summary>
|
||||
public class RolePermission : Entity
|
||||
{
|
||||
public Guid RoleId { get; set; }
|
||||
public Role Role { get; set; } = null!;
|
||||
|
||||
public ModuleType Module { get; set; }
|
||||
public PermissionAction Action { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Zeiterfassung" — geleistete Arbeitszeit eines Mitarbeiters innerhalb eines Auftrags
|
||||
/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.5). Nur freigegebene Einträge (Status "Freigegeben")
|
||||
/// dürfen in die Rechnungserstellung einfließen (FR-ZE-3).
|
||||
/// </summary>
|
||||
public class TimeEntry : AuditableEntity
|
||||
{
|
||||
public Guid EmployeeId { get; set; }
|
||||
public Employee Employee { get; set; } = null!;
|
||||
|
||||
public Guid OrderId { get; set; }
|
||||
public Order Order { get; set; } = null!;
|
||||
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeOnly Start { get; set; }
|
||||
public TimeOnly End { get; set; }
|
||||
public TimeSpan BreakDuration { get; set; }
|
||||
|
||||
public string Status { get; set; } = "Entwurf";
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Systembenutzer (Login-Konto). Getrennt von <see cref="Employee"/>, da nicht jeder Mitarbeiter
|
||||
/// einen App-Zugang hat und nicht jeder Benutzer zwingend ein Mitarbeiter ist (siehe Blueprint 19.1).
|
||||
/// </summary>
|
||||
public class User : AuditableEntity
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Wechselt, wenn eine Session dieses Users erzwungen beendet wird (siehe SessionAdminService).
|
||||
/// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft -
|
||||
/// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users.
|
||||
/// </summary>
|
||||
public Guid SecurityStamp { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Erzwingt beim nächsten Request (außer Passwort-ändern/Logout/Refresh/Me) einen Passwortwechsel -
|
||||
/// gesetzt, wenn ein Admin ein initiales Passwort direkt vergibt statt eine Einladungsmail zu senden.
|
||||
/// </summary>
|
||||
public bool MustChangePassword { get; set; }
|
||||
|
||||
public Guid RoleId { get; set; }
|
||||
public Role Role { get; set; } = null!;
|
||||
|
||||
/// <summary>Optionale Verknüpfung zum Personalakte-Datensatz (nicht jeder Benutzer ist Mitarbeiter).</summary>
|
||||
public Guid? EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public ICollection<UserPermissionOverride> PermissionOverrides { get; set; } = new List<UserPermissionOverride>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Individuell für einen Benutzer gewährtes oder entzogenes Recht — überschreibt den Rollen-Default.
|
||||
/// Siehe Blueprint Kap. 6.5 ("Rollen dienen als Vorlage. Individuelle Rechte können zusätzlich
|
||||
/// vergeben oder entzogen werden.").
|
||||
/// </summary>
|
||||
public class UserPermissionOverride : Entity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public ModuleType Module { get; set; }
|
||||
public PermissionAction Action { get; set; }
|
||||
public PermissionEffect Effect { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace OmsorgCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Fachliche Module, gegen die Rechte geprüft werden (siehe REQUIREMENTS.md Abschnitt 7, Rechtematrix).
|
||||
/// </summary>
|
||||
public enum ModuleType
|
||||
{
|
||||
Employees,
|
||||
Facilities,
|
||||
Contracts,
|
||||
Orders,
|
||||
TimeEntries,
|
||||
Invoices,
|
||||
Recruiting,
|
||||
Controlling,
|
||||
UserManagement
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Mögliche Aktionen innerhalb eines Moduls (siehe Blueprint Kap. 6.5).
|
||||
/// </summary>
|
||||
public enum PermissionAction
|
||||
{
|
||||
View,
|
||||
Create,
|
||||
Edit,
|
||||
Delete,
|
||||
Export,
|
||||
Approve
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user