Add team roles and content management updates
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -8,8 +9,14 @@ public static class AdminDashboardEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi();
|
||||
group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi();
|
||||
group.MapGet("/dashboard", GetDashboard)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Dashboard))
|
||||
.WithName("GetAdminDashboard")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/audit-entries", GetAuditEntries)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Audit))
|
||||
.WithName("GetAdminAuditEntries")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -24,7 +31,7 @@ public static class AdminDashboardEndpoints
|
||||
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
|
||||
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
|
||||
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
|
||||
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null);
|
||||
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.Status == "pending");
|
||||
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
|
||||
|
||||
var topCategoryNames = await db.VoteEntries
|
||||
@@ -76,7 +83,7 @@ public static class AdminDashboardEndpoints
|
||||
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Freitext-Nominierungen mit Review-Bedarf"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"),
|
||||
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
|
||||
},
|
||||
activityItems,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Backend.Domain;
|
||||
using Backend.Security;
|
||||
using Backend.Data;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
@@ -7,4 +8,54 @@ internal static class AdminEndpointConventions
|
||||
{
|
||||
public static UserSession CurrentSession(HttpContext context) =>
|
||||
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
|
||||
|
||||
public static async ValueTask<object?> RequirePermission(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next,
|
||||
string permissionKey)
|
||||
{
|
||||
var session = CurrentSession(context.HttpContext);
|
||||
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||
if (!await AdminPermissionCatalog.HasPermissionAsync(db, session.Role, permissionKey, context.HttpContext.RequestAborted))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = $"Diese Admin-Aktion braucht die Berechtigung '{permissionKey}'." },
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
public static async ValueTask<object?> RequireWritePermission(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next,
|
||||
string permissionKey)
|
||||
{
|
||||
var session = CurrentSession(context.HttpContext);
|
||||
if (AdminRoles.Normalize(session.Role) == AdminRoles.OrganizationTeam)
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Organisation Team hat fuer diesen Bereich nur Leserechte." },
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
return await RequirePermission(context, next, permissionKey);
|
||||
}
|
||||
|
||||
public static async ValueTask<object?> RequireAnyPermission(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next,
|
||||
string[] permissionKeys)
|
||||
{
|
||||
var session = CurrentSession(context.HttpContext);
|
||||
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||
if (!await AdminPermissionCatalog.HasAnyPermissionAsync(db, session.Role, permissionKeys, context.HttpContext.RequestAborted))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Diese Admin-Aktion braucht eine passende Rollenberechtigung." },
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,26 +11,11 @@ public static class AdminEndpoints
|
||||
|
||||
group.MapAdminSiteSettingsEndpoints();
|
||||
|
||||
var managerGroup = group.MapGroup(string.Empty)
|
||||
.AddEndpointFilter(RequireAdminWorkspaceRole);
|
||||
|
||||
managerGroup.MapAdminDashboardEndpoints();
|
||||
managerGroup.MapAdminSeasonManagementEndpoints();
|
||||
managerGroup.MapAdminModerationEndpoints();
|
||||
group.MapAdminDashboardEndpoints();
|
||||
group.MapAdminSeasonManagementEndpoints();
|
||||
group.MapAdminModerationEndpoints();
|
||||
group.MapAdminTeamEndpoints();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async ValueTask<object?> RequireAdminWorkspaceRole(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context.HttpContext);
|
||||
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,42 @@ public static partial class AdminModerationEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi();
|
||||
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi();
|
||||
group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi();
|
||||
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi();
|
||||
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi();
|
||||
group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi();
|
||||
group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").WithOpenApi();
|
||||
group.MapDelete("/clips/{clipId:int}", DeleteClip)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||
.WithName("DeleteAdminClip")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||
.WithName("UpdateAdminClipStatus")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("ApproveAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/risk-flags", GetRiskFlags)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("GetAdminRiskFlags")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("ResolveRiskFlag")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("BulkResolveRiskFlags")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/risk-rules", GetRiskRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("GetAdminRiskRules")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/risk-rules", UpdateRiskRules)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
|
||||
.WithName("UpdateAdminRiskRules")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ public static partial class AdminModerationEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||
? nomination.CandidateText
|
||||
: request.DisplayName.Trim();
|
||||
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||
{
|
||||
@@ -37,11 +35,19 @@ public static partial class AdminModerationEndpoints
|
||||
|
||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
||||
var normalizedDisplayName = rawDisplayName.ToLower();
|
||||
var normalizedChannelSlug = channelSlug.ToLower();
|
||||
var normalizedPlatform = platform.ToLower();
|
||||
|
||||
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||
item.SeasonId == nomination.SeasonId
|
||||
&& item.CategoryId == nomination.CategoryId
|
||||
&& item.DisplayName.ToLower() == rawDisplayName.ToLower());
|
||||
&& (
|
||||
item.DisplayName.ToLower() == normalizedDisplayName
|
||||
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
|
||||
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
|
||||
&& item.Platform.ToLower() == normalizedPlatform)
|
||||
));
|
||||
|
||||
var candidate = existingCandidate;
|
||||
if (candidate is null)
|
||||
@@ -60,6 +66,8 @@ public static partial class AdminModerationEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.DisplayName = rawDisplayName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||
{
|
||||
candidate.ChannelSlug = channelSlug;
|
||||
|
||||
@@ -262,11 +262,6 @@ public static partial class AdminModerationEndpoints
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Risk-Regeln koennen nur Admins oder Owner aendern." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
{
|
||||
|
||||
@@ -64,14 +64,14 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending" && item.CandidateText != null)
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText!,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
|
||||
@@ -4,19 +4,58 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
{
|
||||
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi();
|
||||
group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi();
|
||||
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi();
|
||||
group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi();
|
||||
group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi();
|
||||
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi();
|
||||
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi();
|
||||
group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").WithOpenApi();
|
||||
group.MapGet("/seasons", GetSeasons)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||
.WithName("GetAdminSeasons")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons", CreateSeason)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||
.WithName("CreateAdminSeason")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||
.WithName("GetAdminSeasonDetail")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/seasons/{seasonId:int}", UpdateSeason)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||
.WithName("UpdateAdminSeason")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||
.WithName("DeleteAdminSeason")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("CreateAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/categories/{categoryId:int}", UpdateCategory)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("UpdateAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/categories/{categoryId:int}", DeleteCategory)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
|
||||
.WithName("DeleteAdminCategory")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("CreateAdminCandidate")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("UpdateAdminCandidate")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
|
||||
.WithName("DeleteAdminCandidate")
|
||||
.WithOpenApi();
|
||||
group.MapPost("/seasons/{seasonId:int}/results", SetResult)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("SetAdminResult")
|
||||
.WithOpenApi();
|
||||
group.MapDelete("/results/{resultId:int}", DeleteResult)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
|
||||
.WithName("DeleteAdminResult")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
||||
}
|
||||
|
||||
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||
@@ -61,7 +61,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|
||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
|
||||
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
|
||||
}
|
||||
|
||||
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||
@@ -92,6 +92,9 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
|| value.Contains("archiv")
|
||||
|| value.Contains("complete")
|
||||
|| value.Contains("ended")
|
||||
|| value.Contains("aufbereit")
|
||||
|| value.Contains("vorbereit")
|
||||
|| value.Contains("pause")
|
||||
|| value.Contains("review")
|
||||
|| value.Contains("auswert")
|
||||
|| value.Contains("vot")
|
||||
|
||||
@@ -70,7 +70,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ShowDate = request.ShowDate;
|
||||
season.ShowStartsAt = request.ShowStartsAt;
|
||||
|
||||
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent && !wasCurrent, seasonId, context.RequestAborted);
|
||||
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, seasonId, context.RequestAborted);
|
||||
|
||||
season.IsCurrent = request.IsCurrent;
|
||||
var actionType = "season.update";
|
||||
|
||||
@@ -16,10 +16,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
|
||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi();
|
||||
group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi();
|
||||
group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi();
|
||||
group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi();
|
||||
group.MapGet("/site-settings", GetSiteSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Content))
|
||||
.WithName("GetAdminSiteSettings")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/site-settings", UpdateSiteSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
|
||||
.WithName("UpdateAdminSiteSettings")
|
||||
.WithOpenApi();
|
||||
group.MapGet("/operational-settings", GetOperationalSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("GetAdminOperationalSettings")
|
||||
.WithOpenApi();
|
||||
group.MapPut("/operational-settings", UpdateOperationalSettings)
|
||||
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
||||
.WithName("UpdateAdminOperationalSettings")
|
||||
.WithOpenApi();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -40,8 +52,11 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.PrivacyPolicyUpdatedBy,
|
||||
settings.PrivacyPolicyUpdatedAt,
|
||||
settings.ImprintUrl,
|
||||
settings.ImprintContent,
|
||||
settings.ContactUrl,
|
||||
settings.ContactContent,
|
||||
settings.SponsorsUrl,
|
||||
settings.SponsorsContent,
|
||||
SeasonMappings.ReadSocialLinks(settings),
|
||||
SeasonMappings.ReadFaqItems(settings)));
|
||||
}
|
||||
@@ -53,10 +68,6 @@ public static class AdminSiteSettingsEndpoints
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.CanManageContent(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
if (settings is null)
|
||||
@@ -78,8 +89,11 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
|
||||
settings.ImprintUrl = request.ImprintUrl.Trim();
|
||||
settings.ImprintContent = request.ImprintContent.Trim();
|
||||
settings.ContactUrl = request.ContactUrl.Trim();
|
||||
settings.ContactContent = request.ContactContent.Trim();
|
||||
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
|
||||
@@ -111,6 +125,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
|
||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||
usesDatabaseDemo,
|
||||
usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration),
|
||||
@@ -118,6 +133,12 @@ public static class AdminSiteSettingsEndpoints
|
||||
usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")),
|
||||
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
|
||||
usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"),
|
||||
settings.TwitchAuthManagedByDatabase,
|
||||
twitchSettings.Configured,
|
||||
twitchSettings.ClientId,
|
||||
twitchSettings.ClientSecretSet,
|
||||
twitchSettings.RedirectUri,
|
||||
twitchSettings.Scope,
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||
@@ -144,12 +165,18 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var before = CreateOperationalSettingsSnapshot(settings);
|
||||
var before = CreateOperationalSettingsSnapshot(settings, configuration);
|
||||
var loginIdentifier = request.DemoLoginEmail.Trim();
|
||||
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
|
||||
var displayName = request.DemoLoginDisplayName.Trim();
|
||||
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
|
||||
var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
||||
var twitchClientId = request.TwitchClientId.Trim();
|
||||
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
|
||||
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
|
||||
var twitchScope = request.TwitchScope.Trim();
|
||||
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
if (request.DemoLoginEnabled)
|
||||
{
|
||||
@@ -168,9 +195,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 12)
|
||||
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 10)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." });
|
||||
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 10 Zeichen lang sein." });
|
||||
}
|
||||
|
||||
if (HasAnyTwitchAuthSetting(twitchClientId, twitchClientSecret, twitchRedirectUri, twitchScope)
|
||||
&& string.IsNullOrWhiteSpace(twitchClientId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Twitch OAuth braucht eine Client-ID." });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(twitchClientId)
|
||||
&& string.IsNullOrWhiteSpace(twitchClientSecret)
|
||||
&& !existingTwitchSecretAvailable)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Twitch OAuth braucht beim ersten Speichern ein Client Secret." });
|
||||
}
|
||||
|
||||
settings.DemoLoginManagedByDatabase = true;
|
||||
@@ -178,6 +218,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.DemoLoginEmail = loginIdentifier;
|
||||
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
|
||||
settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName;
|
||||
settings.TwitchClientId = twitchClientId;
|
||||
settings.TwitchRedirectUri = twitchRedirectUri;
|
||||
settings.TwitchScope = twitchScope;
|
||||
if (!string.IsNullOrWhiteSpace(twitchClientSecret))
|
||||
{
|
||||
settings.TwitchClientSecret = twitchClientSecret;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchRedirectUri) && string.IsNullOrWhiteSpace(twitchScope))
|
||||
{
|
||||
settings.TwitchClientSecret = string.Empty;
|
||||
}
|
||||
settings.TwitchAuthManagedByDatabase = HasAnyTwitchAuthSetting(
|
||||
settings.TwitchClientId,
|
||||
settings.TwitchClientSecret,
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope);
|
||||
|
||||
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||
? newPassword
|
||||
@@ -201,8 +257,9 @@ public static class AdminSiteSettingsEndpoints
|
||||
|
||||
var changes = BuildOperationalSettingChanges(
|
||||
before,
|
||||
CreateOperationalSettingsSnapshot(settings),
|
||||
!string.IsNullOrWhiteSpace(passwordToPersist));
|
||||
CreateOperationalSettingsSnapshot(settings, configuration),
|
||||
!string.IsNullOrWhiteSpace(passwordToPersist),
|
||||
!string.IsNullOrWhiteSpace(twitchClientSecret));
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
session.TwitchUserId,
|
||||
@@ -225,6 +282,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
saved = true,
|
||||
demoLoginPasswordSet = HasDatabaseDemoCredentials(settings),
|
||||
twitchClientSecretSet = HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,6 +308,9 @@ public static class AdminSiteSettingsEndpoints
|
||||
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
|
||||
|
||||
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
|
||||
|
||||
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
|
||||
{
|
||||
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
||||
@@ -263,7 +324,35 @@ public static class AdminSiteSettingsEndpoints
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
||||
|
||||
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) =>
|
||||
private static bool HasAnyTwitchAuthSetting(params string[] values) =>
|
||||
values.Any(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
private static bool HasEffectiveTwitchClientSecret(SiteSettings settings, IConfiguration configuration) =>
|
||||
!string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|
||||
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
|
||||
|
||||
private static TwitchSettingsSnapshot ReadEffectiveTwitchSettings(SiteSettings settings, IConfiguration configuration)
|
||||
{
|
||||
var clientId = !string.IsNullOrWhiteSpace(settings.TwitchClientId)
|
||||
? settings.TwitchClientId
|
||||
: ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID");
|
||||
var redirectUri = !string.IsNullOrWhiteSpace(settings.TwitchRedirectUri)
|
||||
? settings.TwitchRedirectUri
|
||||
: ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI");
|
||||
var scope = !string.IsNullOrWhiteSpace(settings.TwitchScope)
|
||||
? settings.TwitchScope
|
||||
: ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE");
|
||||
var secretSet = HasEffectiveTwitchClientSecret(settings, configuration);
|
||||
|
||||
return new(
|
||||
clientId,
|
||||
secretSet,
|
||||
redirectUri,
|
||||
scope,
|
||||
!string.IsNullOrWhiteSpace(clientId) && secretSet);
|
||||
}
|
||||
|
||||
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings, IConfiguration configuration) =>
|
||||
new(
|
||||
settings.DemoLoginManagedByDatabase,
|
||||
settings.DemoLoginEnabled,
|
||||
@@ -271,6 +360,11 @@ public static class AdminSiteSettingsEndpoints
|
||||
HasDatabaseDemoCredentials(settings),
|
||||
settings.DemoLoginTwitchUserId,
|
||||
settings.DemoLoginDisplayName,
|
||||
settings.TwitchAuthManagedByDatabase,
|
||||
settings.TwitchClientId,
|
||||
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||
settings.TwitchRedirectUri,
|
||||
settings.TwitchScope,
|
||||
settings.MaintenanceModeEnabled,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||
@@ -278,7 +372,8 @@ public static class AdminSiteSettingsEndpoints
|
||||
private static object[] BuildOperationalSettingChanges(
|
||||
OperationalSettingsSnapshot before,
|
||||
OperationalSettingsSnapshot after,
|
||||
bool passwordChanged)
|
||||
bool passwordChanged,
|
||||
bool twitchClientSecretChanged)
|
||||
{
|
||||
var changes = new List<object>();
|
||||
|
||||
@@ -287,6 +382,22 @@ public static class AdminSiteSettingsEndpoints
|
||||
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
|
||||
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
|
||||
AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName);
|
||||
AddOperationalChange(changes, "twitchAuthManagedByDatabase", "Twitch OAuth Quelle", before.TwitchAuthManagedByDatabase, after.TwitchAuthManagedByDatabase);
|
||||
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
|
||||
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
|
||||
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
|
||||
|
||||
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
|
||||
{
|
||||
changes.Add(new
|
||||
{
|
||||
field = "twitchClientSecret",
|
||||
label = "Twitch Client Secret",
|
||||
@from = before.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt",
|
||||
to = twitchClientSecretChanged ? "neu gesetzt" : after.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt",
|
||||
sensitive = true,
|
||||
});
|
||||
}
|
||||
|
||||
if (passwordChanged)
|
||||
{
|
||||
@@ -347,7 +458,19 @@ public static class AdminSiteSettingsEndpoints
|
||||
bool DemoLoginPasswordSet,
|
||||
string DemoLoginTwitchUserId,
|
||||
string DemoLoginDisplayName,
|
||||
bool TwitchAuthManagedByDatabase,
|
||||
string TwitchClientId,
|
||||
bool TwitchClientSecretSet,
|
||||
string TwitchRedirectUri,
|
||||
string TwitchScope,
|
||||
bool MaintenanceModeEnabled,
|
||||
string MaintenanceTitle,
|
||||
string MaintenanceMessage);
|
||||
|
||||
private sealed record TwitchSettingsSnapshot(
|
||||
string ClientId,
|
||||
bool ClientSecretSet,
|
||||
string RedirectUri,
|
||||
string Scope,
|
||||
bool Configured);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||
|
||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,36 @@ public static partial class AuthEndpoints
|
||||
.WithName("DemoLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/team-login", TeamLogin)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.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")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/twitch/callback", CompleteTwitchAuthorization)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("CompleteTwitchAuthorization")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/session", GetSession)
|
||||
.WithName("GetSession")
|
||||
.WithOpenApi();
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetSession(HttpContext context, IUserSessionService userSessionService)
|
||||
private static async Task<IResult> GetSession(
|
||||
HttpContext context,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
@@ -14,7 +19,7 @@ public static partial class AuthEndpoints
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
return Results.Ok(ToAuthSessionDto(session));
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
|
||||
@@ -29,10 +34,24 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(new { loggedOut = true });
|
||||
}
|
||||
|
||||
private static AuthSessionDto ToAuthSessionDto(UserSession session) =>
|
||||
new(
|
||||
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||
AwardsDbContext db,
|
||||
UserSession session,
|
||||
bool mustChangePassword = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
|
||||
var sessionRole = teamMember?.Role ?? session.Role;
|
||||
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
|
||||
return new(
|
||||
session.SessionToken,
|
||||
session.TwitchUserId,
|
||||
session.DisplayName,
|
||||
session.Role);
|
||||
teamMember?.DisplayName ?? session.DisplayName,
|
||||
AdminRoles.Normalize(sessionRole),
|
||||
permissionKeys,
|
||||
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||
teamMember?.Login,
|
||||
teamMember?.BoundTwitchUserId,
|
||||
teamMember?.BoundTwitchDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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 partial class AuthEndpoints
|
||||
{
|
||||
private const string TeamSessionPrefix = "team:";
|
||||
private const int MinTeamPasswordLength = 10;
|
||||
|
||||
private static async Task<IResult> TeamLogin(
|
||||
HttpContext context,
|
||||
TeamLoginRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var login = NormalizeTeamLogin(request.Login);
|
||||
var password = request.Password ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
|
||||
if (member is null
|
||||
|| !member.IsActive
|
||||
|| !DemoCredentialHasher.VerifyPassword(password, member.PasswordHash, member.PasswordSalt))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
var session = await userSessionService.CreateSessionAsync(
|
||||
BuildTeamSessionId(member.Login),
|
||||
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> 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,
|
||||
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 nutzt keinen aktiven Team-Login." });
|
||||
}
|
||||
|
||||
var currentPassword = request.CurrentPassword ?? string.Empty;
|
||||
var newPassword = request.NewPassword?.Trim() ?? string.Empty;
|
||||
if (!DemoCredentialHasher.VerifyPassword(currentPassword, member.PasswordHash, member.PasswordSalt))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das aktuelle Passwort stimmt nicht." });
|
||||
}
|
||||
|
||||
if (newPassword.Length < MinTeamPasswordLength)
|
||||
{
|
||||
return Results.BadRequest(new { message = $"Das neue Passwort muss mindestens {MinTeamPasswordLength} Zeichen lang sein." });
|
||||
}
|
||||
|
||||
if (DemoCredentialHasher.FixedTimePlainTextEquals(currentPassword, newPassword))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Das neue Passwort muss sich vom aktuellen Passwort unterscheiden." });
|
||||
}
|
||||
|
||||
var credentials = DemoCredentialHasher.HashPassword(newPassword);
|
||||
member.PasswordHash = credentials.Hash;
|
||||
member.PasswordSalt = credentials.Salt;
|
||||
member.MustChangePassword = false;
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||
session.DisplayName = member.DisplayName;
|
||||
session.Role = AdminRoles.Normalize(member.Role);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
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}";
|
||||
|
||||
internal static string ReadTeamLoginFromSession(string twitchUserId) =>
|
||||
twitchUserId.StartsWith(TeamSessionPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? NormalizeTeamLogin(twitchUserId[TeamSessionPrefix.Length..])
|
||||
: string.Empty;
|
||||
|
||||
internal static async Task<TeamMember?> FindTeamMemberForSessionAsync(
|
||||
AwardsDbContext db,
|
||||
UserSession session,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var login = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||
if (!string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
return await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, cancellationToken);
|
||||
}
|
||||
|
||||
var twitchUserId = NormalizeTwitchUserId(session.TwitchUserId);
|
||||
return string.IsNullOrWhiteSpace(twitchUserId)
|
||||
? null
|
||||
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string NormalizeTeamLogin(string value) =>
|
||||
value.Trim().TrimStart('@').ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTwitchUserId(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTwitchDisplayName(string? value) =>
|
||||
(value ?? string.Empty).Trim();
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Backend.Common;
|
||||
using Backend.Configuration;
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Security;
|
||||
using Backend.Services;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
public static partial class AuthEndpoints
|
||||
{
|
||||
private const string TwitchLoginPurpose = "team-login";
|
||||
private const string TwitchBindingPurpose = "team-binding";
|
||||
private const string TwitchStateCachePrefix = "twitch-oauth-state:";
|
||||
private static readonly TimeSpan TwitchStateLifetime = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static async Task<IResult> StartTwitchAuthorization(
|
||||
HttpContext context,
|
||||
TwitchAuthorizeRequest request,
|
||||
IMemoryCache memoryCache,
|
||||
IOptions<TwitchAuthOptions> twitchOptions,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||
if (!TwitchAuthConfigured(options))
|
||||
{
|
||||
return Results.Json(
|
||||
new { message = "Twitch OAuth ist noch nicht konfiguriert. Bitte TwitchAuth:ClientId und TwitchAuth:ClientSecret setzen." },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var purpose = NormalizeTwitchPurpose(request.Purpose);
|
||||
if (purpose is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Unbekannter Twitch-Login-Zweck." });
|
||||
}
|
||||
|
||||
var frontendOrigin = NormalizeFrontendOrigin(request.FrontendOrigin, configuration, environment);
|
||||
if (frontendOrigin is null)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Frontend-Origin ist fuer Twitch OAuth nicht erlaubt." });
|
||||
}
|
||||
|
||||
var sessionToken = string.Empty;
|
||||
if (purpose == TwitchBindingPurpose)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
sessionToken = session.SessionToken;
|
||||
}
|
||||
|
||||
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
memoryCache.Set(
|
||||
$"{TwitchStateCachePrefix}{state}",
|
||||
new TwitchOAuthState(purpose, NormalizeReturnUrl(request.ReturnUrl), frontendOrigin, sessionToken),
|
||||
TwitchStateLifetime);
|
||||
|
||||
var authorizationParams = new Dictionary<string, string?>
|
||||
{
|
||||
["client_id"] = options.ClientId,
|
||||
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||
["response_type"] = "code",
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Scope))
|
||||
{
|
||||
authorizationParams["scope"] = options.Scope.Trim();
|
||||
}
|
||||
|
||||
var authorizationUrl = QueryHelpers.AddQueryString(
|
||||
"https://id.twitch.tv/oauth2/authorize",
|
||||
authorizationParams);
|
||||
|
||||
return Results.Ok(new TwitchAuthorizeResponse(authorizationUrl));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchAuthorization(
|
||||
HttpContext context,
|
||||
string? code,
|
||||
string? state,
|
||||
string? error,
|
||||
string? error_description,
|
||||
IMemoryCache memoryCache,
|
||||
IOptions<TwitchAuthOptions> twitchOptions,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(state)
|
||||
|| !memoryCache.TryGetValue<TwitchOAuthState>($"{TwitchStateCachePrefix}{state}", out var oauthState)
|
||||
|| oauthState is null)
|
||||
{
|
||||
return RedirectToTwitchCallbackError("/", "Der Twitch-Login ist abgelaufen. Bitte starte ihn erneut.");
|
||||
}
|
||||
|
||||
memoryCache.Remove($"{TwitchStateCachePrefix}{state}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
return RedirectToTwitchCallbackError(
|
||||
oauthState,
|
||||
string.IsNullOrWhiteSpace(error_description)
|
||||
? "Twitch hat den Login abgebrochen."
|
||||
: error_description);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen Login-Code zurueckgegeben.");
|
||||
}
|
||||
|
||||
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||
if (!TwitchAuthConfigured(options))
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Twitch OAuth ist auf dem Server nicht vollstaendig konfiguriert.");
|
||||
}
|
||||
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var token = await ExchangeTwitchCodeAsync(httpClient, context, options, code, context.RequestAborted);
|
||||
if (token is null)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte den Login-Code nicht bestaetigen.");
|
||||
}
|
||||
|
||||
var twitchUser = await LoadTwitchUserAsync(httpClient, options.ClientId, token.AccessToken, context.RequestAborted);
|
||||
if (twitchUser is null)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte dein Profil nicht laden.");
|
||||
}
|
||||
|
||||
var twitchLogin = NormalizeTwitchUserId(twitchUser.Login);
|
||||
if (string.IsNullOrWhiteSpace(twitchLogin))
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen gueltigen Login-Namen geliefert.");
|
||||
}
|
||||
|
||||
if (oauthState.Purpose == TwitchBindingPurpose)
|
||||
{
|
||||
return await CompleteTwitchBindingAsync(context, oauthState, twitchLogin, twitchUser.DisplayName, db);
|
||||
}
|
||||
|
||||
return await CompleteTwitchTeamLoginAsync(context, oauthState, twitchLogin, db, userSessionService);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchBindingAsync(
|
||||
HttpContext context,
|
||||
TwitchOAuthState oauthState,
|
||||
string twitchLogin,
|
||||
string twitchDisplayName,
|
||||
AwardsDbContext db)
|
||||
{
|
||||
var session = await db.UserSessions.FirstOrDefaultAsync(
|
||||
item => item.SessionToken == oauthState.SessionToken && item.IsActive,
|
||||
context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Deine Team-Session ist abgelaufen. Bitte melde dich erneut an.");
|
||||
}
|
||||
|
||||
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Dieser Account ist kein aktiver Team-Login.");
|
||||
}
|
||||
|
||||
if (member.MustChangePassword)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Bitte aendere zuerst dein temporaeres Passwort.");
|
||||
}
|
||||
|
||||
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||
item => item.Id != member.Id && item.BoundTwitchUserId == twitchLogin,
|
||||
context.RequestAborted);
|
||||
if (alreadyBound)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Dieser Twitch-Account ist bereits mit einem Team-Account verbunden.");
|
||||
}
|
||||
|
||||
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||
&& !string.Equals(previousTwitchUserId, twitchLogin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||
{
|
||||
oldSession.IsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
member.BoundTwitchUserId = twitchLogin;
|
||||
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchLogin : twitchDisplayName;
|
||||
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return RedirectToTwitchCallback(oauthState, "connected");
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||
HttpContext context,
|
||||
TwitchOAuthState oauthState,
|
||||
string twitchLogin,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||
item => item.BoundTwitchUserId == twitchLogin,
|
||||
context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return RedirectToTwitchCallbackError(oauthState, "Fuer diesen Twitch-Account ist kein aktiver Team-Account gebunden.");
|
||||
}
|
||||
|
||||
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
var session = await userSessionService.CreateSessionAsync(
|
||||
twitchLogin,
|
||||
member.DisplayName,
|
||||
member.Role,
|
||||
RequestMetadataReader.Read(context),
|
||||
context.RequestAborted);
|
||||
|
||||
return RedirectToTwitchCallback(oauthState, "authenticated", session.SessionToken);
|
||||
}
|
||||
|
||||
private static async Task<TwitchTokenResponse?> ExchangeTwitchCodeAsync(
|
||||
HttpClient httpClient,
|
||||
HttpContext context,
|
||||
TwitchAuthOptions options,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.PostAsync(
|
||||
"https://id.twitch.tv/oauth2/token",
|
||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = options.ClientId,
|
||||
["client_secret"] = options.ClientSecret,
|
||||
["code"] = code,
|
||||
["grant_type"] = "authorization_code",
|
||||
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonSerializer.DeserializeAsync<TwitchTokenResponse>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<TwitchUserResponseItem?> LoadTwitchUserAsync(
|
||||
HttpClient httpClient,
|
||||
string clientId,
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.twitch.tv/helix/users");
|
||||
request.Headers.Add("Client-Id", clientId);
|
||||
request.Headers.Authorization = new("Bearer", accessToken);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var payload = await JsonSerializer.DeserializeAsync<TwitchUsersResponse>(stream, cancellationToken: cancellationToken);
|
||||
return payload?.Data.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IResult RedirectToTwitchCallback(TwitchOAuthState state, string status, string? sessionToken = null, string? message = null)
|
||||
{
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = status,
|
||||
["returnUrl"] = state.ReturnUrl,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
values["sessionToken"] = sessionToken;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
values["message"] = message;
|
||||
}
|
||||
|
||||
var fragment = string.Join('&', values.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"));
|
||||
return Results.Redirect($"{state.FrontendOrigin}/auth/twitch/callback#{fragment}");
|
||||
}
|
||||
|
||||
private static IResult RedirectToTwitchCallbackError(TwitchOAuthState state, string message) =>
|
||||
RedirectToTwitchCallback(state, "error", message: message);
|
||||
|
||||
private static IResult RedirectToTwitchCallbackError(string returnUrl, string message) =>
|
||||
RedirectToTwitchCallback(new TwitchOAuthState(TwitchLoginPurpose, NormalizeReturnUrl(returnUrl), ApplicationDefaults.FrontendOrigins[0], string.Empty), "error", message: message);
|
||||
|
||||
private static bool TwitchAuthConfigured(TwitchAuthOptions options) =>
|
||||
!string.IsNullOrWhiteSpace(options.ClientId)
|
||||
&& !string.IsNullOrWhiteSpace(options.ClientSecret);
|
||||
|
||||
private static async Task<TwitchAuthOptions> ResolveEffectiveTwitchOptionsAsync(
|
||||
AwardsDbContext db,
|
||||
TwitchAuthOptions configuredOptions,
|
||||
IConfiguration configuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||
return new TwitchAuthOptions
|
||||
{
|
||||
ClientId = FirstConfigured(settings?.TwitchClientId, ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"), configuredOptions.ClientId),
|
||||
ClientSecret = FirstConfigured(settings?.TwitchClientSecret, ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"), configuredOptions.ClientSecret),
|
||||
RedirectUri = FirstConfigured(settings?.TwitchRedirectUri, ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"), configuredOptions.RedirectUri),
|
||||
Scope = FirstConfigured(settings?.TwitchScope, ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"), configuredOptions.Scope),
|
||||
};
|
||||
}
|
||||
|
||||
private static string FirstConfigured(params string?[] values) =>
|
||||
values.Select(value => value?.Trim() ?? string.Empty).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
|
||||
|
||||
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
|
||||
|
||||
private static string ResolveRedirectUri(HttpContext context, TwitchAuthOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.RedirectUri))
|
||||
{
|
||||
return options.RedirectUri.Trim();
|
||||
}
|
||||
|
||||
return $"{context.Request.Scheme}://{context.Request.Host}/api/auth/twitch/callback";
|
||||
}
|
||||
|
||||
private static string? NormalizeTwitchPurpose(string? purpose)
|
||||
{
|
||||
var normalized = (purpose ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized is TwitchLoginPurpose or TwitchBindingPurpose ? normalized : null;
|
||||
}
|
||||
|
||||
private static string NormalizeReturnUrl(string? returnUrl)
|
||||
{
|
||||
var normalized = (returnUrl ?? "/admin").Trim();
|
||||
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||
&& !normalized.StartsWith("//", StringComparison.Ordinal)
|
||||
&& !normalized.StartsWith("/auth/twitch/callback", StringComparison.OrdinalIgnoreCase)
|
||||
? normalized
|
||||
: "/admin";
|
||||
}
|
||||
|
||||
private static string? NormalizeFrontendOrigin(
|
||||
string? frontendOrigin,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
if (!Uri.TryCreate(frontendOrigin?.Trim(), UriKind.Absolute, out var uri)
|
||||
|| uri.Scheme is not ("http" or "https")
|
||||
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var origin = uri.GetLeftPart(UriPartial.Authority);
|
||||
var configuredOrigins = configuration
|
||||
.GetSection(FrontendOptions.SectionName)
|
||||
.Get<FrontendOptions>()?
|
||||
.AllowedOrigins ?? [];
|
||||
var allowedOrigins = configuredOrigins.Length > 0 || !environment.IsDevelopment()
|
||||
? configuredOrigins
|
||||
: ApplicationDefaults.FrontendOrigins;
|
||||
|
||||
return allowedOrigins.Any(item => string.Equals(item.TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase))
|
||||
? origin
|
||||
: null;
|
||||
}
|
||||
|
||||
private sealed record TwitchOAuthState(
|
||||
string Purpose,
|
||||
string ReturnUrl,
|
||||
string FrontendOrigin,
|
||||
string SessionToken);
|
||||
|
||||
private sealed record TwitchTokenResponse(
|
||||
[property: JsonPropertyName("access_token")] string AccessToken);
|
||||
|
||||
private sealed record TwitchUsersResponse(
|
||||
[property: JsonPropertyName("data")] TwitchUserResponseItem[] Data);
|
||||
|
||||
private sealed record TwitchUserResponseItem(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("login")] string Login,
|
||||
[property: JsonPropertyName("display_name")] string DisplayName);
|
||||
}
|
||||
@@ -21,12 +21,12 @@ public static partial class PublicEndpoints
|
||||
|
||||
if (submittedNominations.Length is 0 or > 3)
|
||||
{
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
|
||||
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.Name.Length > 120))
|
||||
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." });
|
||||
return Results.BadRequest(new { message = "Legacy nominee names must stay below 120 characters." });
|
||||
}
|
||||
|
||||
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
|
||||
@@ -34,19 +34,14 @@ public static partial class PublicEndpoints
|
||||
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
|
||||
}
|
||||
|
||||
if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl)))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
|
||||
}
|
||||
|
||||
var distinctNomineeNames = submittedNominations
|
||||
.Select(item => item.Name)
|
||||
var distinctStreamUrls = submittedNominations
|
||||
.Select(item => item.StreamUrl)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (distinctNomineeNames.Length != submittedNominations.Length)
|
||||
if (distinctStreamUrls.Length != submittedNominations.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
|
||||
return Results.BadRequest(new { message = "Duplicate stream links are not allowed inside one category." });
|
||||
}
|
||||
|
||||
var invalidStreamUrl = submittedNominations
|
||||
@@ -93,12 +88,10 @@ public static partial class PublicEndpoints
|
||||
SeasonId = category.SeasonId,
|
||||
CategoryId = category.Id,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
CandidateText = nomination.Name,
|
||||
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||
Status = "pending",
|
||||
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
|
||||
? null
|
||||
: $"Stream-Link: {nomination.StreamUrl}",
|
||||
ReviewNote = $"Stream-Link: {nomination.StreamUrl}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
}).ToArray();
|
||||
|
||||
@@ -151,7 +144,7 @@ public static partial class PublicEndpoints
|
||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
||||
}
|
||||
|
||||
private readonly record struct SubmittedNomination(string Name, string StreamUrl);
|
||||
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
|
||||
|
||||
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
|
||||
{
|
||||
@@ -167,15 +160,24 @@ public static partial class PublicEndpoints
|
||||
streamUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
return new SubmittedNomination(name, streamUrl);
|
||||
return new SubmittedNomination(string.IsNullOrWhiteSpace(name) ? null : name, streamUrl);
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return (request.Nominees ?? [])
|
||||
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.Select(item =>
|
||||
{
|
||||
var streamUrl = item.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
|
||||
{
|
||||
streamUrl = normalizedUrl;
|
||||
}
|
||||
|
||||
return new SubmittedNomination(null, streamUrl);
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public static partial class PublicEndpoints
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
||||
.ThenInclude(category => category.Candidates)
|
||||
.OrderByDescending(item => item.Year)
|
||||
.FirstOrDefaultAsync(item => item.IsCurrent);
|
||||
|
||||
if (season is null)
|
||||
@@ -54,6 +55,22 @@ public static partial class PublicEndpoints
|
||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||
.ToArray();
|
||||
|
||||
var archiveYearRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(result => result.Season.Year < season.Year)
|
||||
.GroupBy(result => result.Season.Year)
|
||||
.Select(group => new
|
||||
{
|
||||
Year = group.Key,
|
||||
WinnerCount = group.Count(),
|
||||
})
|
||||
.OrderByDescending(item => item.Year)
|
||||
.ToArrayAsync();
|
||||
|
||||
var archiveYears = archiveYearRows
|
||||
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
|
||||
.ToArray();
|
||||
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||
var publicCategories = season.Categories
|
||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
||||
@@ -72,7 +89,7 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
|
||||
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", phaseKey)),
|
||||
new TimelineItem("review", "Review & Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("review", phaseKey)),
|
||||
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
|
||||
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||
},
|
||||
publicCategories
|
||||
@@ -84,6 +101,7 @@ public static partial class PublicEndpoints
|
||||
category.MaxNomineesPerUser))
|
||||
.ToArray(),
|
||||
winnerPreviewItems,
|
||||
archiveYears,
|
||||
new PublicSiteContentDto(
|
||||
siteSettings.HostDisplayName,
|
||||
siteSettings.HostTagline,
|
||||
|
||||
@@ -39,7 +39,7 @@ public static partial class PublicEndpoints
|
||||
item.Status,
|
||||
Nominee = item.CandidateId != null
|
||||
? item.Candidate!.DisplayName
|
||||
: item.CandidateText,
|
||||
: item.CandidateText ?? item.StreamUrl,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user