Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
+562
View File
@@ -0,0 +1,562 @@
using System.Security.Cryptography;
using System.Text.Json;
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminTeamEndpoints
{
private const int MinPasswordLength = 10;
private const string PersonalOwnerLogin = "jayuhime";
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
[
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
];
private static readonly AdminTeamRoleDto[] DefaultRoles =
[
new(AdminRoles.Owner, "Owner", "Jayuhime: vollständige Kontrolle inklusive Passwortreset und Betriebseinstellungen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Owner)),
new(AdminRoles.Creator, "Creator", "Persönliche Jayuhime-Rolle mit denselben Rechten wie Owner.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Creator)),
new(AdminRoles.Admin, "Admins", "Operatives Kernteam mit Schreibrechten in allen Award-Bereichen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Admin)),
new(AdminRoles.Member, "Mitglied", "Internes Team für Pflege, Nominierungen und Clip-Arbeit.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Member)),
new(AdminRoles.Reviewer, "Reviewer", "Review-Fokus für Nominierungen, Clips und Risiko-Hinweise.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Reviewer)),
new(AdminRoles.OrganizationTeam, "Organisation Team", "Externe Personen mit Read-only-Sicht auf ausgewählte Bereiche.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.OrganizationTeam)),
];
public static RouteGroupBuilder MapAdminTeamEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/team", GetTeam)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Team))
.WithName("GetAdminTeam")
.WithOpenApi();
group.MapPost("/team/members", CreateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("CreateAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/members/{memberId:int}", UpdateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamMember")
.WithOpenApi();
group.MapPost("/team/members/{memberId:int}/reset-password", ResetPassword)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("ResetAdminTeamMemberPassword")
.WithOpenApi();
group.MapDelete("/team/members/{memberId:int}", DeleteMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("DeleteAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/roles", UpdateRoles)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamRoles")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetTeam(AwardsDbContext db)
{
var members = await db.TeamMembers
.AsNoTracking()
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.ToArrayAsync();
return Results.Ok(new AdminTeamResponse(
members,
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
private static async Task<IResult> CreateMember(
HttpContext context,
AwardsDbContext db,
CreateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
if (AdminRoles.IsPrivilegedFullControlRole(normalizedRole) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, null, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
if (await db.TeamMembers.AnyAsync(item => item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
var member = new TeamMember
{
Login = normalizedLogin,
DisplayName = displayName,
Role = normalizedRole,
PasswordHash = credentials.Hash,
PasswordSalt = credentials.Salt,
MustChangePassword = true,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
CreatedByTwitchId = session.TwitchUserId,
};
db.TeamMembers.Add(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.create",
"team-member",
normalizedLogin,
$"Team-Login {displayName} wurde angelegt.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> UpdateMember(
int memberId,
HttpContext context,
AwardsDbContext db,
UpdateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
var loginChanged = !string.Equals(member.Login, normalizedLogin, StringComparison.OrdinalIgnoreCase);
if (loginChanged && !AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Logins können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
}
if (loginChanged && await db.TeamMembers.AnyAsync(item => item.Id != member.Id && item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
if ((AdminRoles.IsPrivilegedFullControlRole(member.Role) || AdminRoles.IsPrivilegedFullControlRole(normalizedRole)) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator bearbeitet werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var previousLogin = member.Login;
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, member.Id, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
member.Login = normalizedLogin;
member.DisplayName = displayName;
member.Role = normalizedRole;
member.IsActive = request.IsActive;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.update",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde aktualisiert.",
new { previousLogin, member.Login, member.DisplayName, member.Role, member.IsActive },
RequestMetadataReader.Read(context));
if (!member.IsActive)
{
DeactivateMemberSessions(db, member, previousLogin);
}
else
{
await SyncMemberSessionRolesAsync(db, member, previousLogin, context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, member = ToMemberDto(member) });
}
private static async Task<IResult> ResetPassword(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Passwort-Reset ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
member.PasswordHash = credentials.Hash;
member.PasswordSalt = credentials.Salt;
member.MustChangePassword = true;
member.PasswordResetAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.password-reset",
"team-member",
member.Id.ToString(),
$"Passwort für {member.DisplayName} wurde zurückgesetzt.",
new { member.Login, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> DeleteMember(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Mitglieder löschen ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
if (AdminRoles.IsPrivilegedFullControlRole(member.Role) && !CanDeletePrivilegedMember(session, member))
{
return Results.Json(new { message = "Creator darf Owner-Konten löschen. Creator-Konten können nur vom eigenen Creator-Account gelöscht werden." }, statusCode: StatusCodes.Status403Forbidden);
}
DeactivateMemberSessions(db, member);
db.TeamMembers.Remove(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.delete",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde gelöscht.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new DeleteTeamMemberResponse(true, memberId));
}
private static async Task<IResult> UpdateRoles(
HttpContext context,
AwardsDbContext db,
UpdateTeamRolesRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
foreach (var roleUpdate in request.Roles ?? [])
{
var role = AdminRoles.Normalize(roleUpdate.Key);
if (!allowedRoles.Contains(role))
{
return Results.BadRequest(new { message = $"Unbekannte Rolle: {roleUpdate.Key}" });
}
var permissionKeys = (roleUpdate.PermissionKeys ?? [])
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
{
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
}
if (!existingRows.TryGetValue(role, out var row))
{
row = new TeamRolePermission { Role = role };
db.TeamRolePermissions.Add(row);
}
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
row.UpdatedAt = DateTimeOffset.UtcNow;
row.UpdatedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"team.roles.update",
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, roles = await BuildRoleDtosAsync(db) });
}
public static async Task<AdminTeamRoleDto[]> BuildRoleDtosAsync(AwardsDbContext db)
{
var overrides = await db.TeamRolePermissions
.AsNoTracking()
.ToDictionaryAsync(item => item.Role, item => item.PermissionsJson);
return DefaultRoles
.Select(role =>
{
var permissions = overrides.TryGetValue(role.Key, out var json)
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
member.Id,
member.Login,
member.DisplayName,
member.Role,
member.BoundTwitchUserId,
member.BoundTwitchDisplayName,
member.IsActive,
member.MustChangePassword,
member.CreatedAt,
member.UpdatedAt,
member.LastLoginAt,
member.TwitchBoundAt,
member.PasswordResetAt);
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
{
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? fallback;
}
catch (JsonException)
{
return fallback;
}
}
private static IResult? ValidateMemberFields(string login, string displayName, string role)
{
if (string.IsNullOrWhiteSpace(login) || login.Length > 80)
{
return Results.BadRequest(new { message = "Login ist erforderlich und darf maximal 80 Zeichen haben." });
}
if (!login.All(value => char.IsLetterOrDigit(value) || value is '_' or '-' or '.'))
{
return Results.BadRequest(new { message = "Login darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten." });
}
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120)
{
return Results.BadRequest(new { message = "Anzeigename ist erforderlich und darf maximal 120 Zeichen haben." });
}
if (!AdminRoles.IsKnownRole(role) || role == AdminRoles.Viewer || role == AdminRoles.ContentAdmin)
{
return Results.BadRequest(new { message = "Bitte wähle Owner, Creator, Admins, Mitglied, Reviewer oder Organisation Team." });
}
return null;
}
private static async Task<IResult?> ValidatePrivilegedMemberChangeAsync(
AwardsDbContext db,
int? memberId,
string login,
string role,
CancellationToken cancellationToken)
{
if (role == AdminRoles.Owner)
{
if (!string.Equals(login, PersonalOwnerLogin, StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Owner-Rolle ist nur für @{PersonalOwnerLogin} vorgesehen." });
}
var ownerExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Owner && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return ownerExists
? Results.Conflict(new { message = "Es darf nur einen Owner-Account geben." })
: null;
}
if (role != AdminRoles.Creator)
{
return null;
}
if (!PersonalCreatorLogins.Contains(login, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Creator-Rolle ist nur für @{string.Join(" oder @", PersonalCreatorLogins)} vorgesehen." });
}
var creatorExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Creator && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return creatorExists
? Results.Conflict(new { message = "Es darf nur einen Creator-Account geben." })
: null;
}
private static void DeactivateMemberSessions(AwardsDbContext db, TeamMember member, string? previousLogin = null)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
foreach (var session in db.UserSessions.Where(item =>
item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
{
session.IsActive = false;
}
}
private static bool IsOwnTeamAccount(UserSession session, TeamMember member)
{
var teamLogin = AuthEndpoints.ReadTeamLoginFromSession(session.TwitchUserId);
if (!string.IsNullOrWhiteSpace(teamLogin)
&& string.Equals(teamLogin, member.Login, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.IsNullOrWhiteSpace(member.BoundTwitchUserId)
&& string.Equals(session.TwitchUserId, member.BoundTwitchUserId, StringComparison.OrdinalIgnoreCase);
}
private static bool CanDeletePrivilegedMember(UserSession session, TeamMember member)
{
if (IsOwnTeamAccount(session, member))
{
return true;
}
return AdminRoles.Normalize(session.Role) == AdminRoles.Creator
&& AdminRoles.Normalize(member.Role) == AdminRoles.Owner;
}
private static async Task SyncMemberSessionRolesAsync(AwardsDbContext db, TeamMember member, string? previousLogin, CancellationToken cancellationToken)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
var sessions = await db.UserSessions
.Where(item => item.IsActive
&& (item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
.ToArrayAsync(cancellationToken);
foreach (var session in sessions)
{
if (session.TwitchUserId == previousTeamSessionId)
{
session.TwitchUserId = teamSessionId;
}
session.DisplayName = member.DisplayName;
session.Role = AdminRoles.Normalize(member.Role);
}
}
private static string NormalizeLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeDisplayName(string value) =>
value.Trim();
private static string GeneratePassword()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!?#%";
Span<char> chars = stackalloc char[MinPasswordLength];
Span<byte> bytes = stackalloc byte[MinPasswordLength];
RandomNumberGenerator.Fill(bytes);
for (var index = 0; index < chars.Length; index++)
{
chars[index] = alphabet[bytes[index] % alphabet.Length];
}
return new string(chars);
}
}