Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
+2
View File
@@ -6,6 +6,8 @@ public static class ApplicationDefaults
[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:4173",
"http://127.0.0.1:4173",
];
+6 -6
View File
@@ -103,9 +103,9 @@ public static class SeasonMappings
return "show";
}
if (value.Contains("review") || value.Contains("auswert"))
if (value.Contains("aufbereit") || value.Contains("vorbereit") || value.Contains("pause") || value.Contains("review") || value.Contains("auswert"))
{
return "review";
return "preparation";
}
if (value.Contains("vot"))
@@ -123,7 +123,7 @@ public static class SeasonMappings
public static string ResolveTimelineState(string itemKey, string currentPhaseKey)
{
string[] phaseOrder = ["nomination", "voting", "review", "show"];
string[] phaseOrder = ["nomination", "voting", "preparation", "show"];
if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase))
{
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
@@ -181,8 +181,8 @@ public static class SeasonMappings
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
[
new FooterLinkDto("Impressum", settings.ImprintUrl),
new FooterLinkDto("Kontakt", settings.ContactUrl),
new FooterLinkDto("Sponsoren & Partner", settings.SponsorsUrl),
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
new FooterLinkDto("sponsors", "Sponsoren & Partner", settings.SponsorsUrl, settings.SponsorsContent),
];
}
@@ -0,0 +1,11 @@
namespace Backend.Configuration;
public sealed class TwitchAuthOptions
{
public const string SectionName = "TwitchAuth";
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string RedirectUri { get; init; } = string.Empty;
public string Scope { get; init; } = string.Empty;
}
@@ -9,8 +9,11 @@ public sealed record AdminSiteSettingsResponse(
string? PrivacyPolicyUpdatedBy,
DateTimeOffset? PrivacyPolicyUpdatedAt,
string ImprintUrl,
string ImprintContent,
string ContactUrl,
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
IEnumerable<PublicSocialLinkDto> SocialLinks,
IEnumerable<FaqItemDto> Faq);
@@ -21,8 +24,11 @@ public sealed record UpdateSiteSettingsRequest(
string PrivacyEmail,
string PrivacyPolicyContent,
string ImprintUrl,
string ImprintContent,
string ContactUrl,
string ContactContent,
string SponsorsUrl,
string SponsorsContent,
PublicSocialLinkDto[] SocialLinks,
FaqItemDto[] Faq);
@@ -33,6 +39,12 @@ public sealed record AdminOperationalSettingsResponse(
bool DemoLoginPasswordSet,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
bool TwitchAuthManagedByDatabase,
bool TwitchAuthConfigured,
string TwitchClientId,
bool TwitchClientSecretSet,
string TwitchRedirectUri,
string TwitchScope,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
@@ -43,6 +55,10 @@ public sealed record UpdateOperationalSettingsRequest(
string? DemoLoginPassword,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
string TwitchClientId,
string? TwitchClientSecret,
string TwitchRedirectUri,
string TwitchScope,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
+63
View File
@@ -0,0 +1,63 @@
namespace Backend.Contracts;
public sealed record AdminTeamResponse(
IEnumerable<AdminTeamMemberDto> Members,
IEnumerable<AdminTeamRoleDto> Roles,
IEnumerable<AdminTeamPermissionDto> Permissions);
public sealed record AdminTeamMemberDto(
int Id,
string Login,
string DisplayName,
string Role,
string? BoundTwitchUserId,
string? BoundTwitchDisplayName,
bool IsActive,
bool MustChangePassword,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? LastLoginAt,
DateTimeOffset? TwitchBoundAt,
DateTimeOffset? PasswordResetAt);
public sealed record AdminTeamRoleDto(
string Key,
string Label,
string Description,
bool IsSystemRole,
IEnumerable<string> PermissionKeys);
public sealed record AdminTeamPermissionDto(
string Key,
string Label,
string Description,
string MenuPath,
bool ReadOnlySupported);
public sealed record CreateTeamMemberRequest(
string Login,
string DisplayName,
string Role);
public sealed record UpdateTeamMemberRequest(
string Login,
string DisplayName,
string Role,
bool IsActive);
public sealed record UpdateTeamRolesRequest(
AdminTeamRoleUpdateDto[] Roles);
public sealed record AdminTeamRoleUpdateDto(
string Key,
string[] PermissionKeys);
public sealed record TeamMemberPasswordResponse(
bool Saved,
int MemberId,
string GeneratedPassword,
bool MustChangePassword);
public sealed record DeleteTeamMemberResponse(
bool Deleted,
int MemberId);
+30 -1
View File
@@ -10,8 +10,37 @@ public sealed record DemoLoginRequest(
string? Email,
string? Password);
public sealed record TeamLoginRequest(
string Login,
string Password);
public sealed record TeamTwitchLoginRequest(
string TwitchUserId,
string? DisplayName);
public sealed record ChangePasswordRequest(
string CurrentPassword,
string NewPassword);
public sealed record BindTeamTwitchRequest(
string TwitchUserId,
string? DisplayName);
public sealed record TwitchAuthorizeRequest(
string Purpose,
string? ReturnUrl,
string? FrontendOrigin);
public sealed record TwitchAuthorizeResponse(
string AuthorizationUrl);
public sealed record AuthSessionDto(
string SessionToken,
string TwitchUserId,
string DisplayName,
string Role);
string Role,
IEnumerable<string> PermissionKeys,
bool MustChangePassword = false,
string? TeamLogin = null,
string? BoundTwitchUserId = null,
string? BoundTwitchDisplayName = null);
+8 -1
View File
@@ -22,6 +22,10 @@ public sealed record WinnerPreviewDto(
string WinnerPlatform,
string WinnerUrl);
public sealed record ArchiveYearDto(
int Year,
int WinnerCount);
public sealed record FaqItemDto(string Question, string Answer);
public sealed record PublicSocialLinkDto(
@@ -33,8 +37,10 @@ public sealed record PublicSocialLinkDto(
bool ShowOnCommunity = true);
public sealed record FooterLinkDto(
string Key,
string Label,
string Url);
string Url,
string Content);
public sealed record PublicSiteContentDto(
string HostDisplayName,
@@ -64,5 +70,6 @@ public sealed record OverviewResponse(
IEnumerable<TimelineItem> Timeline,
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
IEnumerable<WinnerPreviewDto> WinnersPreview,
IEnumerable<ArchiveYearDto> ArchiveYears,
PublicSiteContentDto SiteContent,
IEnumerable<FaqItemDto> Faq);
+1 -1
View File
@@ -1,7 +1,7 @@
namespace Backend.Contracts;
public sealed record NominationEntryRequest(
string Name,
string? Name,
string StreamUrl);
public sealed record CreateNominationRequest(
+28
View File
@@ -17,6 +17,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -43,10 +45,36 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.DemoLoginPasswordSalt).HasMaxLength(80);
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
entity.Property(item => item.DemoLoginDisplayName).HasMaxLength(120);
entity.Property(item => item.TwitchClientId).HasMaxLength(120);
entity.Property(item => item.TwitchClientSecret).HasMaxLength(180);
entity.Property(item => item.TwitchRedirectUri).HasMaxLength(400);
entity.Property(item => item.TwitchScope).HasMaxLength(300);
entity.Property(item => item.MaintenanceTitle).HasMaxLength(120);
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
});
modelBuilder.Entity<TeamMember>(entity =>
{
entity.HasIndex(item => item.Login).IsUnique();
entity.HasIndex(item => item.BoundTwitchUserId).IsUnique();
entity.Property(item => item.Login).HasMaxLength(80);
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.Role).HasMaxLength(40);
entity.Property(item => item.PasswordHash).HasMaxLength(120);
entity.Property(item => item.PasswordSalt).HasMaxLength(80);
entity.Property(item => item.BoundTwitchUserId).HasMaxLength(120);
entity.Property(item => item.BoundTwitchDisplayName).HasMaxLength(120);
entity.Property(item => item.CreatedByTwitchId).HasMaxLength(120);
entity.Property(item => item.UpdatedByTwitchId).HasMaxLength(120);
});
modelBuilder.Entity<TeamRolePermission>(entity =>
{
entity.HasIndex(item => item.Role).IsUnique();
entity.Property(item => item.Role).HasMaxLength(40);
entity.Property(item => item.UpdatedByTwitchId).HasMaxLength(120);
});
modelBuilder.Entity<Category>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
@@ -16,6 +16,21 @@ public static class OperationalTablesBootstrapper
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchAuthManagedByDatabase" boolean NOT NULL DEFAULT false;
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchClientId" character varying(120) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchClientSecret" character varying(180) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchRedirectUri" character varying(400) NOT NULL DEFAULT '';
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "TwitchScope" character varying(300) NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS "RiskFlags" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NULL,
@@ -128,5 +143,52 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
ON "Nominations" ("SeasonId", "Status");
CREATE TABLE IF NOT EXISTS "TeamMembers" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Login" character varying(80) NOT NULL,
"DisplayName" character varying(120) NOT NULL,
"Role" character varying(40) NOT NULL,
"PasswordHash" character varying(120) NOT NULL,
"PasswordSalt" character varying(80) NOT NULL,
"BoundTwitchUserId" character varying(120) NULL,
"BoundTwitchDisplayName" character varying(120) NULL,
"MustChangePassword" boolean NOT NULL,
"IsActive" boolean NOT NULL,
"CreatedByTwitchId" character varying(120) NOT NULL,
"UpdatedByTwitchId" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"UpdatedAt" timestamp with time zone NULL,
"LastLoginAt" timestamp with time zone NULL,
"TwitchBoundAt" timestamp with time zone NULL,
"PasswordResetAt" timestamp with time zone NULL
);
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "BoundTwitchUserId" character varying(120) NULL;
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "BoundTwitchDisplayName" character varying(120) NULL;
ALTER TABLE "TeamMembers"
ADD COLUMN IF NOT EXISTS "TwitchBoundAt" timestamp with time zone NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_Login"
ON "TeamMembers" ("Login");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamMembers_BoundTwitchUserId"
ON "TeamMembers" ("BoundTwitchUserId")
WHERE "BoundTwitchUserId" IS NOT NULL;
CREATE TABLE IF NOT EXISTS "TeamRolePermissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Role" character varying(40) NOT NULL,
"PermissionsJson" text NOT NULL,
"UpdatedByTwitchId" character varying(120) NOT NULL,
"UpdatedAt" timestamp with time zone NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeamRolePermissions_Role"
ON "TeamRolePermissions" ("Role");
""");
}
+29
View File
@@ -55,6 +55,35 @@ internal static class SeedCatalog
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
];
internal const string DefaultImprintContent = """
Anbieter
VTuber Star Awards, vertreten durch Jayuhime.
Kontakt
Nutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.
Hinweis
Dieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.
""";
internal const string DefaultContactContent = """
Kontakt zum Award-Team
Du hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.
Datenschutzfragen
Fuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.
Community & Kooperationen
Social Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.
""";
internal const string DefaultSponsorsContent = """
Sponsoren & Partner
Hier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.
Partner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.
""";
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
[
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
+3
View File
@@ -43,8 +43,11 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
PrivacyPolicyUpdatedBy = "seed",
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
ImprintUrl = "https://vtuber-star-awards.de/impressum",
ImprintContent = SeedCatalog.DefaultImprintContent,
ContactUrl = "https://vtuber-star-awards.de/kontakt",
ContactContent = SeedCatalog.DefaultContactContent,
SponsorsUrl = "https://vtuber-star-awards.de/partner",
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
SocialLinksJson = JsonSerializer.Serialize(new[]
{
@@ -40,6 +40,21 @@ public static partial class SeedDataBootstrapper
{
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
}
if (string.IsNullOrWhiteSpace(settings.ImprintContent))
{
settings.ImprintContent = SeedCatalog.DefaultImprintContent;
}
if (string.IsNullOrWhiteSpace(settings.ContactContent))
{
settings.ContactContent = SeedCatalog.DefaultContactContent;
}
if (string.IsNullOrWhiteSpace(settings.SponsorsContent))
{
settings.SponsorsContent = SeedCatalog.DefaultSponsorsContent;
}
}
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
+8
View File
@@ -11,8 +11,11 @@ public sealed class SiteSettings
public string? PrivacyPolicyUpdatedBy { get; set; }
public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; }
public string ImprintUrl { get; set; } = string.Empty;
public string ImprintContent { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string ContactContent { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
public string SponsorsContent { get; set; } = string.Empty;
public string SocialLinksJson { get; set; } = "[]";
public string FaqJson { get; set; } = "[]";
public string RiskRulesJson { get; set; } = "[]";
@@ -23,6 +26,11 @@ public sealed class SiteSettings
public string DemoLoginPasswordSalt { get; set; } = string.Empty;
public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin";
public string DemoLoginDisplayName { get; set; } = "Jayuhime Admin";
public bool TwitchAuthManagedByDatabase { get; set; }
public string TwitchClientId { get; set; } = string.Empty;
public string TwitchClientSecret { get; set; } = string.Empty;
public string TwitchRedirectUri { get; set; } = string.Empty;
public string TwitchScope { get; set; } = string.Empty;
public bool MaintenanceModeEnabled { get; set; }
public string MaintenanceTitle { get; set; } = "Sternenpause";
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
+22
View File
@@ -0,0 +1,22 @@
namespace Backend.Domain;
public sealed class TeamMember
{
public int Id { get; set; }
public string Login { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string PasswordSalt { get; set; } = string.Empty;
public string? BoundTwitchUserId { get; set; }
public string? BoundTwitchDisplayName { get; set; }
public bool MustChangePassword { get; set; } = true;
public bool IsActive { get; set; } = true;
public string CreatedByTwitchId { get; set; } = string.Empty;
public string? UpdatedByTwitchId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
public DateTimeOffset? TwitchBoundAt { get; set; }
public DateTimeOffset? PasswordResetAt { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
namespace Backend.Domain;
public sealed class TeamRolePermission
{
public int Id { get; set; }
public string Role { get; set; } = string.Empty;
public string PermissionsJson { get; set; } = "[]";
public string UpdatedByTwitchId { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; }
}
+11 -4
View File
@@ -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);
}
}
+4 -19
View File
@@ -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);
}
}
+36 -9
View File
@@ -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";
+138 -15
View File
@@ -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);
}
+562
View File
@@ -0,0 +1,562 @@
using System.Security.Cryptography;
using System.Text.Json;
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminTeamEndpoints
{
private const int MinPasswordLength = 10;
private const string PersonalOwnerLogin = "jayuhime";
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
[
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
];
private static readonly AdminTeamRoleDto[] DefaultRoles =
[
new(AdminRoles.Owner, "Owner", "Jayuhime: vollständige Kontrolle inklusive Passwortreset und Betriebseinstellungen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Owner)),
new(AdminRoles.Creator, "Creator", "Persönliche Jayuhime-Rolle mit denselben Rechten wie Owner.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Creator)),
new(AdminRoles.Admin, "Admins", "Operatives Kernteam mit Schreibrechten in allen Award-Bereichen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Admin)),
new(AdminRoles.Member, "Mitglied", "Internes Team für Pflege, Nominierungen und Clip-Arbeit.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Member)),
new(AdminRoles.Reviewer, "Reviewer", "Review-Fokus für Nominierungen, Clips und Risiko-Hinweise.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Reviewer)),
new(AdminRoles.OrganizationTeam, "Organisation Team", "Externe Personen mit Read-only-Sicht auf ausgewählte Bereiche.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.OrganizationTeam)),
];
public static RouteGroupBuilder MapAdminTeamEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/team", GetTeam)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Team))
.WithName("GetAdminTeam")
.WithOpenApi();
group.MapPost("/team/members", CreateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("CreateAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/members/{memberId:int}", UpdateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamMember")
.WithOpenApi();
group.MapPost("/team/members/{memberId:int}/reset-password", ResetPassword)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("ResetAdminTeamMemberPassword")
.WithOpenApi();
group.MapDelete("/team/members/{memberId:int}", DeleteMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("DeleteAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/roles", UpdateRoles)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamRoles")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetTeam(AwardsDbContext db)
{
var members = await db.TeamMembers
.AsNoTracking()
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.ToArrayAsync();
return Results.Ok(new AdminTeamResponse(
members,
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
private static async Task<IResult> CreateMember(
HttpContext context,
AwardsDbContext db,
CreateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
if (AdminRoles.IsPrivilegedFullControlRole(normalizedRole) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, null, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
if (await db.TeamMembers.AnyAsync(item => item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
var member = new TeamMember
{
Login = normalizedLogin,
DisplayName = displayName,
Role = normalizedRole,
PasswordHash = credentials.Hash,
PasswordSalt = credentials.Salt,
MustChangePassword = true,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
CreatedByTwitchId = session.TwitchUserId,
};
db.TeamMembers.Add(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.create",
"team-member",
normalizedLogin,
$"Team-Login {displayName} wurde angelegt.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> UpdateMember(
int memberId,
HttpContext context,
AwardsDbContext db,
UpdateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
var loginChanged = !string.Equals(member.Login, normalizedLogin, StringComparison.OrdinalIgnoreCase);
if (loginChanged && !AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Logins können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
}
if (loginChanged && await db.TeamMembers.AnyAsync(item => item.Id != member.Id && item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
if ((AdminRoles.IsPrivilegedFullControlRole(member.Role) || AdminRoles.IsPrivilegedFullControlRole(normalizedRole)) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator bearbeitet werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var previousLogin = member.Login;
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, member.Id, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
member.Login = normalizedLogin;
member.DisplayName = displayName;
member.Role = normalizedRole;
member.IsActive = request.IsActive;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.update",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde aktualisiert.",
new { previousLogin, member.Login, member.DisplayName, member.Role, member.IsActive },
RequestMetadataReader.Read(context));
if (!member.IsActive)
{
DeactivateMemberSessions(db, member, previousLogin);
}
else
{
await SyncMemberSessionRolesAsync(db, member, previousLogin, context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, member = ToMemberDto(member) });
}
private static async Task<IResult> ResetPassword(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Passwort-Reset ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
member.PasswordHash = credentials.Hash;
member.PasswordSalt = credentials.Salt;
member.MustChangePassword = true;
member.PasswordResetAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.password-reset",
"team-member",
member.Id.ToString(),
$"Passwort für {member.DisplayName} wurde zurückgesetzt.",
new { member.Login, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> DeleteMember(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Mitglieder löschen ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
if (AdminRoles.IsPrivilegedFullControlRole(member.Role) && !CanDeletePrivilegedMember(session, member))
{
return Results.Json(new { message = "Creator darf Owner-Konten löschen. Creator-Konten können nur vom eigenen Creator-Account gelöscht werden." }, statusCode: StatusCodes.Status403Forbidden);
}
DeactivateMemberSessions(db, member);
db.TeamMembers.Remove(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.delete",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde gelöscht.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new DeleteTeamMemberResponse(true, memberId));
}
private static async Task<IResult> UpdateRoles(
HttpContext context,
AwardsDbContext db,
UpdateTeamRolesRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
foreach (var roleUpdate in request.Roles ?? [])
{
var role = AdminRoles.Normalize(roleUpdate.Key);
if (!allowedRoles.Contains(role))
{
return Results.BadRequest(new { message = $"Unbekannte Rolle: {roleUpdate.Key}" });
}
var permissionKeys = (roleUpdate.PermissionKeys ?? [])
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
{
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
}
if (!existingRows.TryGetValue(role, out var row))
{
row = new TeamRolePermission { Role = role };
db.TeamRolePermissions.Add(row);
}
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
row.UpdatedAt = DateTimeOffset.UtcNow;
row.UpdatedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"team.roles.update",
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, roles = await BuildRoleDtosAsync(db) });
}
public static async Task<AdminTeamRoleDto[]> BuildRoleDtosAsync(AwardsDbContext db)
{
var overrides = await db.TeamRolePermissions
.AsNoTracking()
.ToDictionaryAsync(item => item.Role, item => item.PermissionsJson);
return DefaultRoles
.Select(role =>
{
var permissions = overrides.TryGetValue(role.Key, out var json)
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
member.Id,
member.Login,
member.DisplayName,
member.Role,
member.BoundTwitchUserId,
member.BoundTwitchDisplayName,
member.IsActive,
member.MustChangePassword,
member.CreatedAt,
member.UpdatedAt,
member.LastLoginAt,
member.TwitchBoundAt,
member.PasswordResetAt);
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
{
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? fallback;
}
catch (JsonException)
{
return fallback;
}
}
private static IResult? ValidateMemberFields(string login, string displayName, string role)
{
if (string.IsNullOrWhiteSpace(login) || login.Length > 80)
{
return Results.BadRequest(new { message = "Login ist erforderlich und darf maximal 80 Zeichen haben." });
}
if (!login.All(value => char.IsLetterOrDigit(value) || value is '_' or '-' or '.'))
{
return Results.BadRequest(new { message = "Login darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten." });
}
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120)
{
return Results.BadRequest(new { message = "Anzeigename ist erforderlich und darf maximal 120 Zeichen haben." });
}
if (!AdminRoles.IsKnownRole(role) || role == AdminRoles.Viewer || role == AdminRoles.ContentAdmin)
{
return Results.BadRequest(new { message = "Bitte wähle Owner, Creator, Admins, Mitglied, Reviewer oder Organisation Team." });
}
return null;
}
private static async Task<IResult?> ValidatePrivilegedMemberChangeAsync(
AwardsDbContext db,
int? memberId,
string login,
string role,
CancellationToken cancellationToken)
{
if (role == AdminRoles.Owner)
{
if (!string.Equals(login, PersonalOwnerLogin, StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Owner-Rolle ist nur für @{PersonalOwnerLogin} vorgesehen." });
}
var ownerExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Owner && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return ownerExists
? Results.Conflict(new { message = "Es darf nur einen Owner-Account geben." })
: null;
}
if (role != AdminRoles.Creator)
{
return null;
}
if (!PersonalCreatorLogins.Contains(login, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Creator-Rolle ist nur für @{string.Join(" oder @", PersonalCreatorLogins)} vorgesehen." });
}
var creatorExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Creator && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return creatorExists
? Results.Conflict(new { message = "Es darf nur einen Creator-Account geben." })
: null;
}
private static void DeactivateMemberSessions(AwardsDbContext db, TeamMember member, string? previousLogin = null)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
foreach (var session in db.UserSessions.Where(item =>
item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
{
session.IsActive = false;
}
}
private static bool IsOwnTeamAccount(UserSession session, TeamMember member)
{
var teamLogin = AuthEndpoints.ReadTeamLoginFromSession(session.TwitchUserId);
if (!string.IsNullOrWhiteSpace(teamLogin)
&& string.Equals(teamLogin, member.Login, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.IsNullOrWhiteSpace(member.BoundTwitchUserId)
&& string.Equals(session.TwitchUserId, member.BoundTwitchUserId, StringComparison.OrdinalIgnoreCase);
}
private static bool CanDeletePrivilegedMember(UserSession session, TeamMember member)
{
if (IsOwnTeamAccount(session, member))
{
return true;
}
return AdminRoles.Normalize(session.Role) == AdminRoles.Creator
&& AdminRoles.Normalize(member.Role) == AdminRoles.Owner;
}
private static async Task SyncMemberSessionRolesAsync(AwardsDbContext db, TeamMember member, string? previousLogin, CancellationToken cancellationToken)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
var sessions = await db.UserSessions
.Where(item => item.IsActive
&& (item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
.ToArrayAsync(cancellationToken);
foreach (var session in sessions)
{
if (session.TwitchUserId == previousTeamSessionId)
{
session.TwitchUserId = teamSessionId;
}
session.DisplayName = member.DisplayName;
session.Role = AdminRoles.Normalize(member.Role);
}
}
private static string NormalizeLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeDisplayName(string value) =>
value.Trim();
private static string GeneratePassword()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!?#%";
Span<char> chars = stackalloc char[MinPasswordLength];
Span<byte> bytes = stackalloc byte[MinPasswordLength];
RandomNumberGenerator.Fill(bytes);
for (var index = 0; index < chars.Length; index++)
{
chars[index] = alphabet[bytes[index] % alphabet.Length];
}
return new string(chars);
}
}
+1 -1
View File
@@ -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));
}
}
+30
View File
@@ -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();
+25 -6
View File
@@ -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);
}
}
+221
View File
@@ -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);
}
+23 -21
View File
@@ -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();
}
}
+19 -1
View File
@@ -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();
@@ -23,6 +23,9 @@ public static class ServiceCollectionExtensions
services.AddSwaggerGen();
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
services.AddMemoryCache();
services.AddHttpClient();
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
@@ -0,0 +1,54 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AwardsDbContext))]
[Migration("20260625110000_AddFooterPageContent")]
public partial class AddFooterPageContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
var imprintContent = SeedCatalog.DefaultImprintContent.Replace("'", "''");
var contactContent = SeedCatalog.DefaultContactContent.Replace("'", "''");
var sponsorsContent = SeedCatalog.DefaultSponsorsContent.Replace("'", "''");
migrationBuilder.Sql(
$"""
ALTER TABLE IF EXISTS "SiteSettings"
ADD COLUMN IF NOT EXISTS "ImprintContent" text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "ContactContent" text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "SponsorsContent" text NOT NULL DEFAULT '';
UPDATE "SiteSettings"
SET "ImprintContent" = '{imprintContent}'
WHERE "ImprintContent" IS NULL OR btrim("ImprintContent") = '';
UPDATE "SiteSettings"
SET "ContactContent" = '{contactContent}'
WHERE "ContactContent" IS NULL OR btrim("ContactContent") = '';
UPDATE "SiteSettings"
SET "SponsorsContent" = '{sponsorsContent}'
WHERE "SponsorsContent" IS NULL OR btrim("SponsorsContent") = '';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
ALTER TABLE IF EXISTS "SiteSettings"
DROP COLUMN IF EXISTS "ImprintContent",
DROP COLUMN IF EXISTS "ContactContent",
DROP COLUMN IF EXISTS "SponsorsContent";
""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddTeamManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeamMembers",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Login = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
PasswordHash = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
PasswordSalt = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
MustChangePassword = table.Column<bool>(type: "boolean", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
PasswordResetAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamMembers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TeamRolePermissions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Role = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
PermissionsJson = table.Column<string>(type: "text", nullable: false),
UpdatedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeamRolePermissions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_TeamMembers_Login",
table: "TeamMembers",
column: "Login",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeamRolePermissions_Role",
table: "TeamRolePermissions",
column: "Role",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeamMembers");
migrationBuilder.DropTable(
name: "TeamRolePermissions");
}
}
}
@@ -0,0 +1,66 @@
using System;
using Backend.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AwardsDbContext))]
[Migration("20260625153000_AddCreatorRoleAndTeamTwitchBinding")]
public partial class AddCreatorRoleAndTeamTwitchBinding : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BoundTwitchDisplayName",
table: "TeamMembers",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BoundTwitchUserId",
table: "TeamMembers",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "TwitchBoundAt",
table: "TeamMembers",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TeamMembers_BoundTwitchUserId",
table: "TeamMembers",
column: "BoundTwitchUserId",
unique: true,
filter: "\"BoundTwitchUserId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_TeamMembers_BoundTwitchUserId",
table: "TeamMembers");
migrationBuilder.DropColumn(
name: "BoundTwitchDisplayName",
table: "TeamMembers");
migrationBuilder.DropColumn(
name: "BoundTwitchUserId",
table: "TeamMembers");
migrationBuilder.DropColumn(
name: "TwitchBoundAt",
table: "TeamMembers");
}
}
}
@@ -43,6 +43,11 @@ namespace Backend.Migrations
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedFromIp")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("EntityId")
.IsRequired()
.HasMaxLength(120)
@@ -62,6 +67,11 @@ namespace Backend.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("UserAgent")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.HasKey("Id");
b.ToTable("AdminAuditEntries");
@@ -579,6 +589,10 @@ namespace Backend.Migrations
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("StreamUrl")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
@@ -838,6 +852,10 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ContactContent")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContactUrl")
.IsRequired()
.HasMaxLength(400)
@@ -888,6 +906,10 @@ namespace Backend.Migrations
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("ImprintContent")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ImprintUrl")
.IsRequired()
.HasMaxLength(400)
@@ -935,6 +957,10 @@ namespace Backend.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("SponsorsContent")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SponsorsUrl")
.IsRequired()
.HasMaxLength(400)
@@ -948,6 +974,7 @@ namespace Backend.Migrations
new
{
Id = 1,
ContactContent = "Kontakt zum Award-Team\nDu hast Fragen zur Nominierung, zum Voting, zu Clips oder zur Show? Schreib dem Team ueber die hinterlegte Kontaktseite.\n\nDatenschutzfragen\nFuer Datenschutzanfragen nutze bitte die Datenschutz-E-Mail aus diesem Footer.\n\nCommunity & Kooperationen\nSocial Links und Partneranfragen werden vom Admin-Team gepflegt und koordiniert.",
ContactUrl = "https://vtuber-star-awards.de/kontakt",
DemoLoginDisplayName = "Jayuhime Admin",
DemoLoginEmail = "",
@@ -959,6 +986,7 @@ namespace Backend.Migrations
FaqJson = "[{\"question\":\"Wer darf nominiert werden?\",\"answer\":\"Jede:r aktive deutschsprachige VTuber kann nominiert werden \\u2014 unabh\\u00E4ngig von Follower-Zahl oder Plattform. Die Community schl\\u00E4gt in der Nominierungsphase ihre Favorit:innen vor.\"},{\"question\":\"Wie funktioniert das Voting?\",\"answer\":\"Du meldest dich ausschlie\\u00DFlich mit deinem Twitch-Account an \\u2014 nur so kannst du teilnehmen. Das h\\u00E4lt das Voting fair und bot-frei. Danach vergibst du pro Kategorie eine Stimme, \\u00E4nderbar bis zum Ende der Phase.\"},{\"question\":\"Was kostet die Teilnahme?\",\"answer\":\"Gar nichts. Nominieren, voten und die Show schauen ist komplett kostenlos \\u2014 der VTuber Star Award ist ein Community-Event von Fans f\\u00FCr Fans.\"},{\"question\":\"Wann und wo findet die Award-Show statt?\",\"answer\":\"Die gro\\u00DFe Live-Show wird von Jayuhime gehostet und auf Twitch \\u0026 YouTube gestreamt. Den genauen Termin findest du im Countdown oben \\u2014 sei live dabei, wenn die Stars gek\\u00FCrt werden!\"},{\"question\":\"Ich wurde nominiert \\u2014 was nun?\",\"answer\":\"Gl\\u00FCckwunsch! Du erh\\u00E4ltst eine Benachrichtigung mit allen Infos. Teile deine Nominierung gern mit deiner Community und ruf zum Voten auf \\u2014 jede Stimme z\\u00E4hlt.\"}]",
HostDisplayName = "Jayuhime",
HostTagline = "VTuber & Award Host",
ImprintContent = "Anbieter\nVTuber Star Awards, vertreten durch Jayuhime.\n\nKontakt\nNutze fuer organisatorische Fragen bitte die Kontaktseite oder die hinterlegte Kontaktadresse.\n\nHinweis\nDieses Impressum ist ein redaktioneller Platzhalter und sollte vor dem Livegang mit den tatsaechlichen Anbieterangaben ersetzt werden.",
ImprintUrl = "https://vtuber-star-awards.de/impressum",
MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.",
MaintenanceModeEnabled = false,
@@ -968,12 +996,128 @@ namespace Backend.Migrations
PrivacyPolicyContent = "Verantwortliche:r\nVTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de\n\nWelche Daten wir verarbeiten\nBei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine Twitch-User-ID sowie den Zeitstempel deiner Aktion.\nFür Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.\n\nRechtsgrundlage\nVerarbeitung auf Basis von Art. 6 Abs. 1 lit. b DSGVO. Für optionale Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO.\n\nZweck der Verarbeitung\nDurchführung des VTuber Star Awards, Sicherstellung fairer Abstimmung, Spam-Prävention sowie Admin-Review von Einreichungen.\n\nLöschfristen\nAlle Teilnahmedaten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. E-Mail-Adressen werden nach der Show entfernt.\n\nDeine Rechte\nDu hast das Recht auf Auskunft, Löschung, Berichtigung, Widerspruch und Beschwerde bei der zuständigen Aufsichtsbehörde.\n\nWeitergabe an Dritte\nKeine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten ausschließlich im Rahmen der Auftragsverarbeitung.",
PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
PrivacyPolicyUpdatedBy = "seed",
RiskRulesJson = "[]",
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
SponsorsUrl = "https://vtuber-star-awards.de/partner"
});
});
modelBuilder.Entity("Backend.Domain.TeamMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("BoundTwitchDisplayName")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("BoundTwitchUserId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Login")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("PasswordResetAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<DateTimeOffset?>("TwitchBoundAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedByTwitchId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("Login")
.IsUnique();
b.HasIndex("BoundTwitchUserId")
.IsUnique();
b.ToTable("TeamMembers");
});
modelBuilder.Entity("Backend.Domain.TeamRolePermission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("Role")
.IsUnique();
b.ToTable("TeamRolePermissions");
});
modelBuilder.Entity("Backend.Domain.UserSession", b =>
{
b.Property<Guid>("Id")
+126
View File
@@ -0,0 +1,126 @@
using System.Text.Json;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Security;
public static class AdminPermissionCatalog
{
public const string Dashboard = "dashboard";
public const string Years = "years";
public const string Nominations = "nominations";
public const string Categories = "categories";
public const string Candidates = "candidates";
public const string Clips = "clips";
public const string Risk = "risk";
public const string Audit = "audit";
public const string Analytics = "analytics";
public const string Winners = "winners";
public const string Content = "content";
public const string Settings = "settings";
public const string Team = "team";
public static readonly string[] AllPermissionKeys =
[
Dashboard,
Years,
Nominations,
Categories,
Candidates,
Clips,
Risk,
Audit,
Analytics,
Winners,
Content,
Settings,
Team,
];
public static readonly string[] SeasonReadPermissionKeys =
[
Dashboard,
Years,
Nominations,
Categories,
Candidates,
Clips,
Analytics,
Winners,
];
public static bool IsKnownPermission(string permissionKey) =>
AllPermissionKeys.Contains(permissionKey, StringComparer.OrdinalIgnoreCase);
public static string[] DefaultPermissionKeys(string? role)
{
return AdminRoles.Normalize(role) switch
{
AdminRoles.Owner => AllPermissionKeys,
AdminRoles.Creator => AllPermissionKeys,
AdminRoles.Admin => AllPermissionKeys.Where(key => key is not Settings).ToArray(),
AdminRoles.Member => [Dashboard, Nominations, Categories, Candidates, Clips, Content],
AdminRoles.Reviewer => [Dashboard, Nominations, Clips, Risk, Audit],
AdminRoles.OrganizationTeam => [Dashboard, Content, Analytics, Winners, Settings],
AdminRoles.ContentAdmin => [Content, Settings],
_ => [],
};
}
public static async Task<string[]> GetPermissionKeysAsync(
AwardsDbContext db,
string? role,
CancellationToken cancellationToken = default)
{
var normalizedRole = AdminRoles.Normalize(role);
if (normalizedRole is AdminRoles.Owner or AdminRoles.Creator)
{
return AllPermissionKeys;
}
var fallback = DefaultPermissionKeys(normalizedRole);
var overrideJson = await db.TeamRolePermissions
.AsNoTracking()
.Where(item => item.Role == normalizedRole)
.Select(item => item.PermissionsJson)
.FirstOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(overrideJson))
{
return fallback;
}
try
{
return (JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback)
.Where(IsKnownPermission)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
}
catch (JsonException)
{
return fallback;
}
}
public static async Task<bool> HasPermissionAsync(
AwardsDbContext db,
string? role,
string permissionKey,
CancellationToken cancellationToken = default)
{
var permissions = await GetPermissionKeysAsync(db, role, cancellationToken);
return permissions.Contains(permissionKey, StringComparer.OrdinalIgnoreCase);
}
public static async Task<bool> HasAnyPermissionAsync(
AwardsDbContext db,
string? role,
IEnumerable<string> permissionKeys,
CancellationToken cancellationToken = default)
{
var permissions = await GetPermissionKeysAsync(db, role, cancellationToken);
return permissionKeys.Any(permission => permissions.Contains(permission, StringComparer.OrdinalIgnoreCase));
}
}
+32 -5
View File
@@ -3,9 +3,13 @@ namespace Backend.Security;
public static class AdminRoles
{
public const string Viewer = "viewer";
public const string Member = "member";
public const string Reviewer = "reviewer";
public const string OrganizationTeam = "organization_team";
public const string ContentAdmin = "content_admin";
public const string Admin = "admin";
public const string Owner = "owner";
public const string Creator = "creator";
public static string Normalize(string? role)
{
@@ -14,8 +18,19 @@ public static class AdminRoles
return normalizedRole switch
{
Owner => Owner,
Creator => Creator,
Admin => Admin,
ContentAdmin => ContentAdmin,
"creator_role" => Creator,
"creatorrolle" => Creator,
"admins" => Admin,
"mitglied" => Member,
"member" => Member,
"reviewer" => Reviewer,
"organisation_team" => OrganizationTeam,
"organisationteam" => OrganizationTeam,
"organization_team" => OrganizationTeam,
"organizationteam" => OrganizationTeam,
_ => Viewer,
};
}
@@ -23,18 +38,30 @@ public static class AdminRoles
public static bool IsKnownRole(string? role)
{
var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_');
return normalizedRole is Viewer or ContentAdmin or Admin or Owner;
return Normalize(normalizedRole) is Viewer or Member or Reviewer or OrganizationTeam or ContentAdmin or Admin or Owner or Creator;
}
public static bool CanAccessAdmin(string? role) =>
Normalize(role) is ContentAdmin or Admin or Owner;
Normalize(role) is Member or Reviewer or OrganizationTeam or ContentAdmin or Admin or Owner or Creator;
public static bool CanManageContent(string? role) =>
Normalize(role) is ContentAdmin or Admin or Owner;
Normalize(role) is ContentAdmin or Admin or Owner or Creator;
public static bool CanManageAdminWorkspace(string? role) =>
Normalize(role) is Admin or Owner;
Normalize(role) is Admin or Owner or Creator;
public static bool CanManageOperationalSettings(string? role) =>
Normalize(role) is Owner;
Normalize(role) is Owner or Creator;
public static bool CanManageTeam(string? role) =>
Normalize(role) is Admin or Owner or Creator;
public static bool CanResetTeamPasswords(string? role) =>
Normalize(role) is Owner or Creator;
public static bool CanDeleteTeamMembers(string? role) =>
Normalize(role) is Owner or Creator;
public static bool IsPrivilegedFullControlRole(string? role) =>
Normalize(role) is Owner or Creator;
}
+20 -1
View File
@@ -1,8 +1,10 @@
using Backend.Data;
using Backend.Endpoints;
using Backend.Services;
namespace Backend.Security;
public sealed class AdminSessionFilter(IUserSessionService userSessionService) : IEndpointFilter
public sealed class AdminSessionFilter(IUserSessionService userSessionService, AwardsDbContext db) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
@@ -17,6 +19,23 @@ public sealed class AdminSessionFilter(IUserSessionService userSessionService) :
return Results.Json(new { message = "Admin access requires an elevated role." }, statusCode: StatusCodes.Status403Forbidden);
}
var teamMember = await AuthEndpoints.FindTeamMemberForSessionAsync(db, session, context.HttpContext.RequestAborted);
if (teamMember is not null)
{
if (!teamMember.IsActive)
{
return Results.Unauthorized();
}
if (teamMember.MustChangePassword)
{
return Results.Json(new { message = "Bitte ändere zuerst dein temporäres Passwort." }, statusCode: StatusCodes.Status403Forbidden);
}
session.DisplayName = teamMember.DisplayName;
session.Role = AdminRoles.Normalize(teamMember.Role);
}
context.HttpContext.SetCurrentSession(session);
return await next(context);
}
+6
View File
@@ -21,6 +21,12 @@
"TwitchUserId": "jayuhime_admin",
"DisplayName": "Jayuhime Admin"
},
"TwitchAuth": {
"ClientId": "",
"ClientSecret": "",
"RedirectUri": "",
"Scope": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
+6
View File
@@ -16,6 +16,12 @@
"TwitchUserId": "jayuhime_admin",
"DisplayName": "Jayuhime Admin"
},
"TwitchAuth": {
"ClientId": "",
"ClientSecret": "",
"RedirectUri": "",
"Scope": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",