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.", "Betrieb", "/admin/dashboard", true), new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "Betrieb", "/admin/nominations", false), new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "Awards", "/admin/years", false), new(AdminPermissionCatalog.Categories, "Kategorien", "Hauptkategorien, Unterkategorien und Limits verwalten.", "Awards", "/admin/categories", false), new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis, Clips und Annahmestatus pflegen.", "Awards", "/admin/candidates", false), new(AdminPermissionCatalog.Clips, "Clips", "Optionale Clip-Einreichungen prüfen.", "Awards", "/admin/clips", false), new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Footer, Showacts und öffentliche Inhalte pflegen.", "Landingpage", "/admin/content", false), new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "Kontrolle", "/admin/risk", true), new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "Kontrolle", "/admin/users-logs", true), new(AdminPermissionCatalog.Analytics, "Analytics", "Jahresmetriken und Überblick lesen.", "Auswertung", "/admin/analytics", true), new(AdminPermissionCatalog.Voting, "Voting", "Stimmenlage und Gewinner-Vorbereitung sehen.", "Auswertung", "/admin/voting", true), new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "Auswertung", "/admin/winners", false), new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang, Wartung und Workflow-Steuerung sehen.", "Einstellungen", "/admin/settings", true), new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "Einstellungen", "/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 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 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 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 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 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 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(); 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 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? 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 MemberSessionKeys(TeamMember member) { yield return $"team:{member.Login}"; if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId)) { yield return member.BoundTwitchUserId; } } private static IEnumerable ReadPermissionKeys(string json, IEnumerable fallback) { try { return JsonSerializer.Deserialize(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 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 chars = stackalloc char[MinPasswordLength]; Span 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); } }