Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
@@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
if (urlValidationError is not null)
{
return urlValidationError;
}
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = request.NewsletterUrl.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
}
settings.ImprintUrl = request.ImprintUrl.Trim();
settings.ImprintUrl = normalizedUrls.ImprintUrl;
settings.ImprintContent = request.ImprintContent.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.ContactUrl = normalizedUrls.ContactUrl;
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
adminAuditService.AddEntry(
@@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints
{
settings.HostDisplayName,
privacyChanged,
socialLinkCount = request.SocialLinks?.Length ?? 0,
socialLinkCount = socialLinks.Length,
faqCount = request.Faq?.Length ?? 0,
},
RequestMetadataReader.Read(context));
@@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,
out PublicSocialLinkDto[] socialLinks)
{
normalizedUrls = new PublicSiteUrlSettings();
socialLinks = [];
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedUrls = new PublicSiteUrlSettings
{
NewsletterUrl = newsletterUrl,
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
foreach (var social in request.SocialLinks ?? [])
{
if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedSocialLinks.Add(social with { Url = normalizedUrl });
}
socialLinks = normalizedSocialLinks.ToArray();
return null;
}
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
{
normalizedUrl = (value ?? string.Empty).Trim();
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return true;
}
if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
errorMessage = $"{fieldName} muss eine absolute http/https-URL sein.";
return false;
}
normalizedUrl = uri.ToString();
return true;
}
private sealed class PublicSiteUrlSettings
{
public string NewsletterUrl { get; set; } = string.Empty;
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
+67 -10
View File
@@ -13,6 +13,7 @@ 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"];
@@ -79,11 +80,21 @@ public static class AdminTeamEndpoints
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.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,
members.Select(member => ToMemberDto(member, sessions, now)),
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
@@ -322,10 +333,15 @@ public static class AdminTeamEndpoints
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 ?? [])
{
@@ -339,14 +355,18 @@ public static class AdminTeamEndpoints
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.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 };
@@ -356,6 +376,18 @@ public static class AdminTeamEndpoints
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(
@@ -364,7 +396,7 @@ public static class AdminTeamEndpoints
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
@@ -384,18 +416,30 @@ public static class AdminTeamEndpoints
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
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,
@@ -407,8 +451,21 @@ public static class AdminTeamEndpoints
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)
{
-10
View File
@@ -23,21 +23,11 @@ public static partial class AuthEndpoints
.WithName("TeamLogin")
.WithOpenApi();
group.MapPost("/team-twitch-login", TeamTwitchLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("TeamTwitchLogin")
.WithOpenApi();
group.MapPost("/password/change", ChangePassword)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("ChangePassword")
.WithOpenApi();
group.MapPost("/team/twitch-binding", BindTeamTwitch)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("BindTeamTwitch")
.WithOpenApi();
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("StartTwitchAuthorization")
@@ -47,38 +47,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> TeamTwitchLogin(
HttpContext context,
TeamTwitchLoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.Unauthorized();
}
var member = await db.TeamMembers.FirstOrDefaultAsync(
item => item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.Unauthorized();
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
twitchUserId,
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> ChangePassword(
HttpContext context,
ChangePasswordRequest request,
@@ -127,64 +95,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static async Task<IResult> BindTeamTwitch(
HttpContext context,
BindTeamTwitchRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
}
if (member.MustChangePassword)
{
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
}
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
}
var alreadyBound = await db.TeamMembers.AnyAsync(
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (alreadyBound)
{
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
}
var previousTwitchUserId = member.BoundTwitchUserId;
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
{
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
{
oldSession.IsActive = false;
}
}
member.BoundTwitchUserId = twitchUserId;
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
member.TwitchBoundAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static string BuildTeamSessionId(string login) =>
$"{TeamSessionPrefix}{login}";
@@ -215,7 +125,4 @@ public static partial class AuthEndpoints
private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchDisplayName(string? value) =>
(value ?? string.Empty).Trim();
}
+55 -24
View File
@@ -4,6 +4,7 @@ using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Backend.Endpoints;
@@ -72,29 +73,7 @@ public static partial class PublicEndpoints
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
@@ -102,7 +81,21 @@ public static partial class PublicEndpoints
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
await db.SaveChangesAsync(context.RequestAborted);
try
{
await db.SaveChangesAsync(context.RequestAborted);
}
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
{
db.ChangeTracker.Clear();
ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
var ballotLink = new
{
label = "Voting-Analytics öffnen",
@@ -142,4 +135,42 @@ public static partial class PublicEndpoints
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
}
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
AwardsDbContext db,
VoteBallot? ballot,
int seasonId,
string submitterId,
VoteEntryRequest[] entries,
CancellationToken cancellationToken)
{
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = seasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot, cancellationToken);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
return ballot;
}
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
}
@@ -9,10 +9,25 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
}
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
{
return Results.Ok(new WinnerArchiveResponse(year, []));
}
var winnerRows = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.Where(result => result.SeasonId == season.Id)
.OrderBy(result => result.CategoryName)
.Select(result => new
{
@@ -34,4 +49,10 @@ public static partial class PublicEndpoints
return Results.Ok(new WinnerArchiveResponse(year, items));
}
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
{
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
return phaseKey is "completed";
}
}