630 lines
28 KiB
C#
630 lines
28 KiB
C#
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 static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
|
|
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)
|
|
.ToArrayAsync();
|
|
var sessionKeys = members
|
|
.SelectMany(MemberSessionKeys)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
var sessions = sessionKeys.Length == 0
|
|
? []
|
|
: await db.UserSessions
|
|
.AsNoTracking()
|
|
.Where(item => sessionKeys.Contains(item.TwitchUserId))
|
|
.ToArrayAsync();
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
return Results.Ok(new AdminTeamResponse(
|
|
members.Select(member => ToMemberDto(member, sessions, now)),
|
|
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 (normalizedRole == AdminRoles.Creator)
|
|
{
|
|
return Results.Json(new { message = "Creator-Konten werden fest vorkonfiguriert und können nicht manuell angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
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 (normalizedRole == AdminRoles.Creator && member.Role != AdminRoles.Creator)
|
|
{
|
|
return Results.Json(new { message = "Creator-Konten werden fest vorkonfiguriert und können nicht manuell zugewiesen werden." }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
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);
|
|
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
|
|
{
|
|
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
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);
|
|
var roleChanges = new List<object>();
|
|
|
|
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)
|
|
.ToArray();
|
|
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
|
|
|
|
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
|
|
{
|
|
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
|
|
}
|
|
|
|
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
|
|
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
|
|
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
|
|
|
|
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;
|
|
|
|
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
roleChanges.Add(new
|
|
{
|
|
role,
|
|
before = beforePermissions,
|
|
after = permissionKeys,
|
|
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
|
|
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
|
|
});
|
|
}
|
|
}
|
|
|
|
adminAuditService.AddEntry(
|
|
session.TwitchUserId,
|
|
"team.roles.update",
|
|
"team-role",
|
|
"matrix",
|
|
"Team-Rollenberechtigungen wurden gespeichert.",
|
|
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
|
|
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;
|
|
|
|
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
|
|
|
|
return role with { PermissionKeys = permissions };
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
private static AdminTeamMemberDto ToMemberDto(
|
|
TeamMember member,
|
|
IReadOnlyCollection<UserSession>? sessions = null,
|
|
DateTimeOffset? now = null)
|
|
{
|
|
var memberSessions = sessions?
|
|
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
|
|
.ToArray() ?? [];
|
|
var lastSeenAt = memberSessions
|
|
.Select(session => (DateTimeOffset?)session.LastSeenAt)
|
|
.Max() ?? member.LastLoginAt;
|
|
var isOnline = member.IsActive
|
|
&& memberSessions.Any(session =>
|
|
session.IsActive
|
|
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
|
|
|
|
return new(
|
|
member.Id,
|
|
member.Login,
|
|
member.DisplayName,
|
|
member.Role,
|
|
member.BoundTwitchUserId,
|
|
member.BoundTwitchDisplayName,
|
|
member.IsActive,
|
|
member.MustChangePassword,
|
|
member.CreatedAt,
|
|
member.UpdatedAt,
|
|
member.LastLoginAt,
|
|
lastSeenAt,
|
|
isOnline,
|
|
member.TwitchBoundAt,
|
|
member.PasswordResetAt);
|
|
}
|
|
|
|
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
|
|
{
|
|
yield return $"team:{member.Login}";
|
|
|
|
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
|
|
{
|
|
yield return member.BoundTwitchUserId;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|