diff --git a/backend-tests/TaskBoardTests.cs b/backend-tests/TaskBoardTests.cs
new file mode 100644
index 0000000..375639a
--- /dev/null
+++ b/backend-tests/TaskBoardTests.cs
@@ -0,0 +1,153 @@
+using Nexus.Api.Data;
+using Xunit;
+
+namespace Nexus.Api.Tests;
+
+public class TaskBoardTests
+{
+ // ── TaskStateHelper: BoardGroupKey ──
+
+ [Theory]
+ [InlineData("Backlog", "offen")]
+ [InlineData("In progress", "inProgress")]
+ [InlineData("Delegated", "delegated")]
+ [InlineData("Review", "review")]
+ [InlineData("Blocked", "blocked")]
+ [InlineData("Done", "done")]
+ [InlineData("backlog", "offen")]
+ [InlineData("in progress", "inProgress")]
+ [InlineData("delegated", "delegated")]
+ [InlineData("review", "review")]
+ [InlineData("blocked", "blocked")]
+ [InlineData("done", "done")]
+ [InlineData("", "offen")]
+ [InlineData(null, "offen")]
+ [InlineData("unknown", "offen")]
+ public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
+ {
+ var result = TaskStateHelper.BoardGroupKey(state);
+ Assert.Equal(expected, result);
+ }
+
+ // ── TaskStateHelper: BoardGroupToState ──
+
+ [Theory]
+ [InlineData("offen", "Backlog")]
+ [InlineData("inProgress", "In progress")]
+ [InlineData("inprogress", "In progress")]
+ [InlineData("delegated", "Delegated")]
+ [InlineData("review", "Review")]
+ [InlineData("blocked", "Blocked")]
+ [InlineData("done", "Done")]
+ [InlineData("Offen", "Backlog")]
+ [InlineData("", null)]
+ [InlineData(null, null)]
+ [InlineData("unknown", null)]
+ public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
+ {
+ var result = TaskStateHelper.BoardGroupToState(groupKey);
+ Assert.Equal(expected, result);
+ }
+
+ // ── TaskStateHelper: AllStates has 6 entries ──
+
+ [Fact]
+ public void AllStates_ContainsAllSixStates()
+ {
+ var states = TaskStateHelper.AllStates;
+ Assert.Equal(6, states.Length);
+ Assert.Contains("Backlog", states);
+ Assert.Contains("In progress", states);
+ Assert.Contains("Delegated", states);
+ Assert.Contains("Review", states);
+ Assert.Contains("Blocked", states);
+ Assert.Contains("Done", states);
+ }
+
+ // ── TaskStateHelper: IsValidState ──
+
+ [Theory]
+ [InlineData("Backlog", true)]
+ [InlineData("In progress", true)]
+ [InlineData("Delegated", true)]
+ [InlineData("Review", true)]
+ [InlineData("Blocked", true)]
+ [InlineData("Done", true)]
+ [InlineData("backlog", true)]
+ [InlineData("offen", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ [InlineData("unknown", false)]
+ public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
+ {
+ Assert.Equal(expected, TaskStateHelper.IsValidState(state));
+ }
+
+ // ── TaskStateHelper: IsInProgressOrBlocked ──
+
+ [Theory]
+ [InlineData("In progress", true)]
+ [InlineData("Blocked", true)]
+ [InlineData("Backlog", false)]
+ [InlineData("Delegated", false)]
+ [InlineData("Review", false)]
+ [InlineData("Done", false)]
+ [InlineData(null, false)]
+ public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
+ {
+ Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
+ }
+
+ // ── TaskStateHelper: IsDoneOrBacklog ──
+
+ [Theory]
+ [InlineData("Done", true)]
+ [InlineData("Backlog", true)]
+ [InlineData("In progress", false)]
+ [InlineData("Delegated", false)]
+ [InlineData("Review", false)]
+ [InlineData("Blocked", false)]
+ [InlineData(null, false)]
+ public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
+ {
+ Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
+ }
+
+ // ── TaskStateHelper: ToDisplayString ──
+
+ [Theory]
+ [InlineData("Backlog", "Offen")]
+ [InlineData("In progress", "In Bearbeitung")]
+ [InlineData("Delegated", "Delegiert")]
+ [InlineData("Review", "Review")]
+ [InlineData("Blocked", "Blockiert")]
+ [InlineData("Done", "Erledigt")]
+ [InlineData("backlog", "Offen")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ [InlineData("unknown", "unknown")]
+ public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
+ {
+ Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
+ }
+
+ // ── TaskState helper: ToStateString and ToTaskState roundtrip ──
+
+ [Fact]
+ public void ToStateString_And_ToTaskState_RoundTrip()
+ {
+ var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Delegated, TaskState.Review, TaskState.Blocked, TaskState.Done };
+ foreach (var state in states)
+ {
+ var str = state.ToStateString();
+ var parsed = str.ToTaskState();
+ Assert.Equal(state, parsed);
+ }
+ }
+
+ [Fact]
+ public void ToTaskState_DefaultsToBacklog_ForUnknownString()
+ {
+ Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
+ }
+}
diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs
index 7363bff..bea7da1 100644
--- a/backend/Controllers/AdminController.cs
+++ b/backend/Controllers/AdminController.cs
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
@@ -8,15 +7,26 @@ using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
+///
+/// Admin/User-Management – erreichbar für owner und admin-Rollen.
+///
+/// Sicherheitsregeln:
+/// - Nur owner und admin dürfen User verwalten.
+/// - Die Rolle "owner" kann weder vergeben noch überschrieben werden – sie ist
+/// eine Sonderrolle, die nur bei der initialen Seed-Erstellung gesetzt wird.
+/// - Über die API sind nur die Rollen "admin", "user" und "viewer" wählbar.
+///
[ApiController]
[Route("api/v1/admin")]
-[Authorize(Roles = "owner")]
+[Authorize(Roles = "owner,admin")]
public class AdminController(
IUserRepository userRepository,
ILogger logger) : ControllerBase
{
+ private static readonly string[] SettableRoles = ["admin", "user", "viewer"];
+
///
- /// List all registered users.
+ /// Alle registrierten User auflisten.
///
[HttpGet("users")]
public async Task GetUsers(CancellationToken ct)
@@ -35,8 +45,8 @@ public class AdminController(
}
///
- /// Create a new user account (admin only).
- /// Email muss eindeutig sein, Passwort mindestens 10 Zeichen.
+ /// Neuen User anlegen.
+ /// Die Rolle "owner" kann NICHT gesetzt werden.
///
[HttpPost("users")]
public async Task CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct)
@@ -53,6 +63,14 @@ public class AdminController(
["password"] = ["Password must be at least 10 characters."]
});
+ // Role validieren – owner ist nicht über API setzbar
+ var targetRole = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant();
+ if (!SettableRoles.Contains(targetRole))
+ return Results.ValidationProblem(new Dictionary
+ {
+ ["role"] = [$"Invalid role. Valid roles: {string.Join(", ", SettableRoles)}."]
+ });
+
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
if (existing is not null)
@@ -66,11 +84,11 @@ public class AdminController(
? request.Email.Split('@')[0]
: request.DisplayName.Trim(),
PasswordHash = PasswordSecurity.Hash(request.Password),
- Role = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant(),
+ Role = targetRole,
};
await userRepository.AddAsync(user, ct);
- logger.LogInformation("Admin created user {Email} with role {Role}", user.Email, user.Role);
+ logger.LogInformation("User {Role} created user {Email} with role {Role}", UserRole(), user.Email, user.Role);
return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo
{
@@ -83,7 +101,7 @@ public class AdminController(
}
///
- /// Delete a user account (admin only, cannot delete owner).
+ /// User löschen. Eigene owner-User und der eigene Account sind geschützt.
///
[HttpDelete("users/{id:guid}")]
public async Task DeleteUser(Guid id, CancellationToken ct)
@@ -93,15 +111,18 @@ public class AdminController(
return Results.NotFound(new { error = "User not found." });
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
- return Results.Forbid();
+ return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
+
+ if (user.Id.ToString() == CurrentUserId())
+ return Results.Problem("You cannot delete your own account.", statusCode: 403);
await userRepository.DeleteAsync(user, ct);
- logger.LogInformation("Admin deleted user {Email}", user.Email);
+ logger.LogInformation("User {Role} deleted user {Email}", UserRole(), user.Email);
return Results.NoContent();
}
///
- /// Update a user's role (admin only, cannot change owner role).
+ /// Rolle eines Users ändern. "owner" kann weder gesetzt noch überschrieben werden.
///
[HttpPatch("users/{id:guid}/role")]
public async Task UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct)
@@ -112,24 +133,35 @@ public class AdminController(
["role"] = ["Role is required."]
});
- var validRoles = new[] { "owner", "admin", "user", "viewer" };
- if (!validRoles.Contains(request.Role.ToLowerInvariant()))
+ var newRole = request.Role.Trim().ToLowerInvariant();
+ if (!SettableRoles.Contains(newRole))
return Results.ValidationProblem(new Dictionary
{
- ["role"] = ["Invalid role. Valid roles: owner, admin, user, viewer."]
+ ["role"] = [$"Invalid role. Valid: {string.Join(", ", SettableRoles)}. Owner is reserved."]
});
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
+ // Niemals owner überschreiben
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
- return Results.Forbid();
+ return Results.Problem("Owner role cannot be modified via API.", statusCode: 403);
- user.Role = request.Role.Trim().ToLowerInvariant();
+ // admin darf andere admins nicht ändern (nur owner)
+ var callerRole = UserRole();
+ if (callerRole == "admin" && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
+ return Results.Problem("Admin users can only be managed by the owner.", statusCode: 403);
+
+ // admin darf sich nicht selbst herabstufen
+ if (callerRole == "admin" && user.Id.ToString() == CurrentUserId() && newRole != "admin")
+ return Results.Problem("You cannot demote yourself.", statusCode: 403);
+
+ user.Role = newRole;
user.UpdatedAt = DateTimeOffset.UtcNow;
await userRepository.UpdateAsync(user, ct);
- logger.LogInformation("Admin updated role for {Email} to {Role}", user.Email, user.Role);
+ logger.LogInformation("User {Role} changed role for {Email} from {OldRole} to {NewRole}",
+ callerRole, user.Email, user.Role, newRole);
return Results.Ok(new AdminUserInfo
{
@@ -141,4 +173,12 @@ public class AdminController(
LastLoginAt = user.LastLoginAt,
});
}
+
+ /// Liefert die Rolle des aufrufenden Users.
+ private string UserRole()
+ => User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value?.ToLowerInvariant() ?? "unknown";
+
+ /// Liefert die Subject-ID des aufrufenden Users.
+ private string? CurrentUserId()
+ => User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
}
diff --git a/frontend/.corepack-home/v1/pnpm/10.12.1/.corepack b/frontend/.corepack-home/v1/pnpm/10.12.1/.corepack
new file mode 100644
index 0000000..bbec780
--- /dev/null
+++ b/frontend/.corepack-home/v1/pnpm/10.12.1/.corepack
@@ -0,0 +1 @@
+{"locator":{"name":"pnpm","reference":"10.12.1"},"bin":{"pnpm":"./bin/pnpm.cjs","pnpx":"./bin/pnpx.cjs"},"hash":"sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"}
\ No newline at end of file
diff --git a/frontend/.corepack-home/v1/pnpm/10.12.1/LICENSE b/frontend/.corepack-home/v1/pnpm/10.12.1/LICENSE
new file mode 100644
index 0000000..a4c8771
--- /dev/null
+++ b/frontend/.corepack-home/v1/pnpm/10.12.1/LICENSE
@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
+Copyright (c) 2016-2025 Zoltan Kochan and other contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/frontend/.corepack-home/v1/pnpm/10.12.1/README.md b/frontend/.corepack-home/v1/pnpm/10.12.1/README.md
new file mode 100644
index 0000000..20a7ef2
--- /dev/null
+++ b/frontend/.corepack-home/v1/pnpm/10.12.1/README.md
@@ -0,0 +1,212 @@
+[简体中文](https://pnpm.io/zh/) |
+[日本語](https://pnpm.io/ja/) |
+[한국어](https://pnpm.io/ko/) |
+[Italiano](https://pnpm.io/it/) |
+[Português Brasileiro](https://pnpm.io/pt/)
+
+
+
+
+
+
+
+Fast, disk space efficient package manager:
+
+* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
+* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
+* **[Great for monorepos](https://pnpm.io/workspaces).**
+* **Strict.** A package can access only dependencies that are specified in its `package.json`.
+* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
+* **Works as a Node.js version manager.** See [pnpm env use](https://pnpm.io/cli/env).
+* **Works everywhere.** Supports Windows, Linux, and macOS.
+* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/users) since 2016.
+* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
+
+To quote the [Rush](https://rushjs.io/) team:
+
+> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and we’ve found it to be very fast and reliable.
+
+[](https://github.com/pnpm/pnpm/releases/latest)
+[](https://r.pnpm.io/chat)
+[](https://opencollective.com/pnpm)
+[](https://opencollective.com/pnpm)
+[](https://x.com/intent/follow?screen_name=pnpmjs®ion=follow_link)
+[](https://stand-with-ukraine.pp.ua)
+
+## Platinum Sponsors
+
+