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",
-131
View File
@@ -1,131 +0,0 @@
<svg viewBox="0 0 720 880" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Jayuhime Keyvisual (stilisierter Platzhalter)">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#efe6ff"/>
<stop offset="55%" stop-color="#f6ecff"/>
<stop offset="100%" stop-color="#fff1da"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="34%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffe39b"/>
<stop offset="55%" stop-color="#f6b938"/>
<stop offset="100%" stop-color="#d98e1d"/>
</linearGradient>
<linearGradient id="robe" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b6bff"/>
<stop offset="100%" stop-color="#5b34c9"/>
</linearGradient>
<linearGradient id="robeDark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6f4fe0"/>
<stop offset="100%" stop-color="#4a279f"/>
</linearGradient>
<linearGradient id="hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ece4fb"/>
</linearGradient>
<linearGradient id="rainbow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="28%" stop-color="#f5a9d6"/>
<stop offset="52%" stop-color="#fcd34d"/>
<stop offset="76%" stop-color="#86efac"/>
<stop offset="100%" stop-color="#7dd3fc"/>
</linearGradient>
</defs>
<rect width="720" height="880" fill="url(#bg)"/>
<rect width="720" height="880" fill="url(#glow)"/>
<!-- sparkles -->
<g fill="#f6b938" opacity="0.85">
<circle cx="120" cy="130" r="4"/><circle cx="610" cy="100" r="5"/><circle cx="665" cy="250" r="3"/>
<circle cx="70" cy="350" r="3"/><circle cx="650" cy="470" r="4"/><circle cx="150" cy="540" r="3"/>
</g>
<g fill="#a78bff" opacity="0.65">
<circle cx="205" cy="90" r="3"/><circle cx="545" cy="170" r="3"/><circle cx="80" cy="230" r="4"/><circle cx="605" cy="370" r="3"/>
</g>
<g fill="#ffffff">
<path d="M150 200 l6 14 14 6 -14 6 -6 14 -6 -14 -14 -6 14 -6z" opacity="0.9"/>
<path d="M585 300 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.85"/>
<path d="M120 640 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.8"/>
</g>
<!-- soft halo behind host -->
<circle cx="360" cy="300" r="210" fill="#ffffff" opacity="0.45"/>
<!-- ===== back hair (long locks framing the body) ===== -->
<path d="M286 250 C232 360 226 520 252 690 C272 650 300 648 318 596 C300 470 300 350 322 286 Z" fill="url(#hair)"/>
<path d="M434 250 C488 360 494 520 468 690 C448 650 420 648 402 596 C420 470 420 350 398 286 Z" fill="url(#hair)"/>
<!-- rainbow streaks inside the locks -->
<path d="M296 320 C268 420 266 540 286 660 C300 624 308 600 308 560 C298 470 298 384 308 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<path d="M424 320 C452 420 454 540 434 660 C420 624 412 600 412 560 C422 470 422 384 412 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<!-- ===== dress (bell skirt) ===== -->
<path d="M322 452 C300 600 250 760 214 868 C300 884 420 884 506 868 C470 760 420 600 398 452 Z" fill="url(#robe)"/>
<!-- skirt shading -->
<path d="M360 452 C352 600 340 760 332 868 L388 868 C380 760 368 600 360 452 Z" fill="#ffffff" opacity="0.12"/>
<!-- gold star accents on skirt -->
<g fill="url(#gold)" opacity="0.92">
<path d="M300 640 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
<path d="M420 600 l4 10 11 4 -11 4 -4 10 -4 -10 -11 -4 11 -4z"/>
<path d="M360 730 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
</g>
<!-- white ruffle hem -->
<path d="M214 862 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 52 0 l0 22 -340 0 z" fill="#ffffff" opacity="0.95"/>
<!-- ===== bodice ===== -->
<path d="M316 352 C322 336 398 336 404 352 L400 458 L320 458 Z" fill="url(#robeDark)"/>
<!-- star cutout on chest -->
<path d="M360 392 l8 18 19 7 -19 8 -8 18 -8 -18 -19 -8 19 -7z" fill="#f6ecff" opacity="0.85"/>
<!-- waist sash -->
<rect x="318" y="446" width="84" height="16" rx="8" fill="url(#gold)"/>
<!-- ===== lower (left) arm + puff sleeve ===== -->
<path d="M322 372 C300 420 292 470 300 512" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="318" cy="372" r="26" fill="#ffffff"/>
<!-- ===== raised (right) arm holding trophy ===== -->
<path d="M402 372 C448 350 486 300 500 250" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="402" cy="372" r="26" fill="#ffffff"/>
<circle cx="500" cy="246" r="15" fill="#fff4ec"/>
<!-- ===== neck + collar ruffle ===== -->
<rect x="350" y="300" width="20" height="40" rx="9" fill="#fff4ec"/>
<path d="M324 344 q18 -16 36 0 q18 16 36 0 l0 14 -72 0 z" fill="#ffffff"/>
<!-- ===== face ===== -->
<circle cx="360" cy="252" r="60" fill="#fff4ec"/>
<!-- cheeks -->
<circle cx="326" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<circle cx="394" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<!-- eyes -->
<ellipse cx="341" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<ellipse cx="379" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<circle cx="343" cy="251" r="2" fill="#ffffff"/>
<circle cx="381" cy="251" r="2" fill="#ffffff"/>
<!-- smile -->
<path d="M349 276 q11 9 22 0" fill="none" stroke="#caa6a0" stroke-width="3" stroke-linecap="round"/>
<!-- monocle on right eye -->
<circle cx="379" cy="254" r="17" fill="none" stroke="url(#gold)" stroke-width="3.5"/>
<path d="M379 271 q5 24 22 32" fill="none" stroke="url(#gold)" stroke-width="2" stroke-linecap="round"/>
<!-- ===== bangs over forehead ===== -->
<path d="M300 250 C300 168 330 138 360 138 C390 138 420 168 420 250 C402 222 384 214 360 214 C336 214 318 222 300 250 Z" fill="url(#hair)"/>
<path d="M360 214 C346 214 334 220 326 234 L334 250 C342 230 378 230 386 250 L394 234 C386 220 374 214 360 214 Z" fill="#ece4fb" opacity="0.6"/>
<!-- ===== star hairbuns ===== -->
<path d="M296 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<path d="M424 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<!-- ===== trophy in raised hand ===== -->
<g transform="translate(458 110)">
<path d="M22 0 h84 v28 q0 50 -42 64 q-42 -14 -42 -64 z" fill="url(#gold)"/>
<path d="M22 7 h-22 q0 36 30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<path d="M106 7 h22 q0 36 -30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<rect x="58" y="90" width="12" height="30" fill="url(#gold)"/>
<rect x="40" y="118" width="48" height="14" rx="5" fill="url(#gold)"/>
<path d="M64 16 l8 19 20 1 -15 13 5 20 -18 -11 -18 11 5 -20 -15 -13 20 -1z" fill="#ffffff" opacity="0.95"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

@@ -1,100 +0,0 @@
<svg width="1536" height="1000" viewBox="0 0 1536 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="backCard" x1="783" y1="193" x2="1072" y2="633" gradientUnits="userSpaceOnUse">
<stop stop-color="#C599FF"/>
<stop offset=".46" stop-color="#8E65EE"/>
<stop offset="1" stop-color="#5D40BB"/>
</linearGradient>
<linearGradient id="backCardShade" x1="909" y1="236" x2="1049" y2="680" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF7FF" stop-opacity=".34"/>
<stop offset="1" stop-color="#2F166F" stop-opacity=".24"/>
</linearGradient>
<linearGradient id="paperFill" x1="442" y1="244" x2="820" y2="713" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset=".58" stop-color="#FFF8FC"/>
<stop offset="1" stop-color="#F7EDFF"/>
</linearGradient>
<linearGradient id="paperStroke" x1="381" y1="253" x2="672" y2="768" gradientUnits="userSpaceOnUse">
<stop stop-color="#F08CAD"/>
<stop offset="1" stop-color="#F5C58D"/>
</linearGradient>
<linearGradient id="feather" x1="869" y1="224" x2="1102" y2="660" gradientUnits="userSpaceOnUse">
<stop stop-color="#E18CF9"/>
<stop offset=".45" stop-color="#8B5AEA"/>
<stop offset="1" stop-color="#4D2BAE"/>
</linearGradient>
<linearGradient id="featherLight" x1="982" y1="219" x2="903" y2="577" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD3ED" stop-opacity=".9"/>
<stop offset="1" stop-color="#C8A8FF" stop-opacity=".2"/>
</linearGradient>
<linearGradient id="starGold" x1="568" y1="360" x2="679" y2="495" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEAB0"/>
<stop offset=".55" stop-color="#FFC86C"/>
<stop offset="1" stop-color="#F29C44"/>
</linearGradient>
<linearGradient id="shadowFade" x1="365" y1="768" x2="1084" y2="829" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB36B" stop-opacity=".18"/>
<stop offset=".6" stop-color="#9B6DFF" stop-opacity=".14"/>
<stop offset="1" stop-color="#FFB36B" stop-opacity="0"/>
</linearGradient>
<filter id="cardShadow" x="0" y="0" width="1536" height="1000" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="34" stdDeviation="36" flood-color="#7551D6" flood-opacity=".18"/>
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#F3A75C" flood-opacity=".1"/>
</filter>
<filter id="sparkGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<clipPath id="paperClip">
<path d="M378 246C390 220 420 208 449 214L720 267C757 274 779 309 770 346L686 718C679 749 651 770 619 768L329 749C293 747 269 711 283 678L337 549C344 532 344 512 337 495L301 406C291 381 299 353 320 337L352 313C360 307 366 299 370 290L378 246Z"/>
</clipPath>
</defs>
<g filter="url(#sparkGlow)" opacity=".9">
<path d="M180 236l17 41 41 17-41 17-17 41-17-41-41-17 41-17 17-41z" fill="#FFC96B"/>
<path d="M235 647l14 34 34 14-34 14-14 34-14-34-34-14 34-14 14-34z" fill="#FFD98E"/>
<path d="M1247 292l14 34 34 14-34 14-14 34-14-34-34-14 34-14 14-34z" fill="#FFC96B"/>
<path d="M1329 593l18 44 44 18-44 18-18 44-18-44-44-18 44-18 18-44z" fill="#FFD98E"/>
<path d="M108 565l7 17 17 7-17 7-7 17-7-17-17-7 17-7 7-17z" fill="#F8B861"/>
<path d="M352 249l8 20 20 8-20 8-8 20-8-20-20-8 20-8 8-20z" fill="#F6C978"/>
<path d="M1362 399l8 20 20 8-20 8-8 20-8-20-20-8 20-8 8-20z" fill="#F8B861"/>
<circle cx="322" cy="575" r="9" fill="#FFC46A"/>
<circle cx="1165" cy="623" r="8" fill="#FFD98E"/>
<circle cx="129" cy="409" r="6" fill="#FFD98E"/>
</g>
<ellipse cx="748" cy="789" rx="443" ry="44" fill="url(#shadowFade)"/>
<g filter="url(#cardShadow)">
<path d="M739 206C739 186 756 170 776 172L1014 191C1034 193 1048 210 1047 230L1025 690C1024 713 1004 729 982 725L744 685C726 682 713 666 714 648L739 206Z" fill="url(#backCard)"/>
<path d="M775 216L1007 235L988 669L759 632L775 216Z" fill="url(#backCardShade)"/>
<path d="M784 211L1012 231" stroke="#D2B5FF" stroke-width="18" stroke-linecap="round" opacity=".45"/>
<path d="M1005 265L985 664" stroke="#3F268E" stroke-width="13" stroke-linecap="round" opacity=".24"/>
<path d="M378 246C390 220 420 208 449 214L720 267C757 274 779 309 770 346L686 718C679 749 651 770 619 768L329 749C293 747 269 711 283 678L337 549C344 532 344 512 337 495L301 406C291 381 299 353 320 337L352 313C360 307 366 299 370 290L378 246Z" fill="url(#paperFill)" stroke="url(#paperStroke)" stroke-width="16" stroke-linejoin="round"/>
<g clip-path="url(#paperClip)">
<path d="M284 678C347 651 422 650 506 675C569 693 630 696 688 681L674 759L310 744L284 678Z" fill="#FFEFE3"/>
<path d="M343 324C441 340 575 368 743 409" stroke="#F8C4D0" stroke-width="7" opacity=".45"/>
</g>
<path d="M590 352l34 75 82 9-61 55 17 81-72-41-72 41 17-81-61-55 82-9 34-75z" fill="url(#starGold)" stroke="#F7B354" stroke-width="17" stroke-linejoin="round"/>
<path d="M591 397l18 39 43 5-32 29 9 42-38-21-38 21 9-42-32-29 43-5 18-39z" fill="#FFF7F0" opacity=".45"/>
<path d="M420 514h190" stroke="#CFB9EE" stroke-width="21" stroke-linecap="round"/>
<path d="M418 595h213" stroke="#C4AFE8" stroke-width="21" stroke-linecap="round"/>
<path d="M414 675h139" stroke="#BBA5E5" stroke-width="21" stroke-linecap="round"/>
<circle cx="405" cy="427" r="10" fill="#F4C4C5"/>
<path d="M827 602C926 424 1067 286 1267 195C1177 355 1057 521 868 657L765 698L827 602Z" fill="url(#feather)" stroke="#6E45C9" stroke-width="16" stroke-linejoin="round"/>
<path d="M867 582C966 451 1084 338 1232 223C1154 338 1044 470 868 657" fill="url(#featherLight)"/>
<path d="M819 638C923 525 1049 403 1207 244" stroke="#4C2FA7" stroke-width="16" stroke-linecap="round"/>
<path d="M938 443C1002 411 1075 365 1158 305" stroke="#A783F5" stroke-width="11" stroke-linecap="round" opacity=".78"/>
<path d="M887 527C938 498 992 459 1049 410" stroke="#C7ABFF" stroke-width="9" stroke-linecap="round" opacity=".75"/>
<path d="M809 616L747 724" stroke="#F4A344" stroke-width="18" stroke-linecap="round"/>
<path d="M747 724L706 750" stroke="#BF6E37" stroke-width="16" stroke-linecap="round"/>
<path d="M724 741C752 761 779 766 805 755C799 785 774 811 735 816C708 812 687 798 672 774L724 741Z" fill="#FFF6FA" stroke="#C6A2DB" stroke-width="13" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

-34
View File
@@ -1,34 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="paper" x1="126" y1="47" x2="252" y2="183" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8FF"/>
<stop offset="1" stop-color="#F4ECFF"/>
</linearGradient>
<linearGradient id="violet" x1="230" y1="38" x2="318" y2="160" gradientUnits="userSpaceOnUse">
<stop stop-color="#B98CFF"/>
<stop offset="1" stop-color="#7353D9"/>
</linearGradient>
<linearGradient id="gold" x1="229" y1="57" x2="262" y2="93" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE6A8"/>
<stop offset="1" stop-color="#F2AF36"/>
</linearGradient>
<filter id="softShadow" x="75" y="26" width="286" height="190" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="16" stdDeviation="16" flood-color="#7C5CFF" flood-opacity="0.16"/>
</filter>
</defs>
<g opacity=".85">
<path d="M67 66l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM350 54l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM332 146l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z" fill="#F8C263"/>
<path d="M98 151l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#B78CFF"/>
</g>
<g filter="url(#softShadow)">
<rect x="182" y="43" width="118" height="135" rx="12" fill="url(#violet)" stroke="#9A7BEE" stroke-width="5"/>
<rect x="199" y="32" width="108" height="135" rx="12" fill="#A77BFA" stroke="#7C5CFF" stroke-width="5"/>
<rect x="111" y="64" width="124" height="139" rx="14" fill="url(#paper)" stroke="#F0A3B9" stroke-width="5"/>
<path d="M138 104h61M138 124h58M138 144h42" stroke="#B8A6D9" stroke-width="7" stroke-linecap="round"/>
<path d="M175 77l10 23 25 3-18 16 5 25-22-13-22 13 5-25-18-16 25-3 10-23z" fill="url(#gold)" stroke="#F0B851" stroke-width="5" stroke-linejoin="round"/>
<path d="M240 158c28-43 60-72 105-83-27 33-54 71-101 100l-18 5 14-22z" fill="#815BE7" stroke="#6B49CB" stroke-width="5" stroke-linejoin="round"/>
<path d="M264 144c22-23 45-42 70-58" stroke="#D8C5FF" stroke-width="5" stroke-linecap="round"/>
<path d="M222 186c19-11 28-18 37-35" stroke="#F2B85F" stroke-width="7" stroke-linecap="round"/>
</g>
<ellipse cx="211" cy="218" rx="119" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-35
View File
@@ -1,35 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="box" x1="111" y1="91" x2="291" y2="214" gradientUnits="userSpaceOnUse">
<stop stop-color="#A67BFF"/>
<stop offset=".55" stop-color="#7653D9"/>
<stop offset="1" stop-color="#5C3AB9"/>
</linearGradient>
<linearGradient id="lid" x1="127" y1="56" x2="294" y2="128" gradientUnits="userSpaceOnUse">
<stop stop-color="#DDBBFF"/>
<stop offset="1" stop-color="#8B63ED"/>
</linearGradient>
<filter id="softShadow" x="79" y="38" width="268" height="205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#6E4DD4" flood-opacity=".18"/>
</filter>
</defs>
<g opacity=".85">
<path d="M70 57l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM341 71l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM332 168l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z" fill="#F8C263"/>
<path d="M101 159l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#B78CFF"/>
</g>
<path d="M89 147c-18-25 5-48 35-38M321 138c22-12 18-42-12-52" stroke="#F3D4FF" stroke-width="8" stroke-linecap="round" opacity=".65"/>
<g filter="url(#softShadow)">
<path d="M178 42l86 20 24 78-118-24-26-57c-3-7 4-15 12-13l22-4z" fill="#FFF8FF" stroke="#D7A7E8" stroke-width="5"/>
<path d="M218 80l8 18 20 2-15 13 5 20-18-10-18 10 5-20-15-13 20-2 8-18z" fill="#F8C263"/>
<rect x="105" y="99" width="210" height="40" rx="10" fill="url(#lid)" stroke="#8C67E8" stroke-width="5"/>
<path d="M124 128h172l-17 84H142l-18-84z" fill="url(#box)" stroke="#6D4EC8" stroke-width="5" stroke-linejoin="round"/>
<path d="M153 140l13 56M267 140l-13 56" stroke="#A78BFA" stroke-width="4" opacity=".7"/>
<path d="M210 158c-9-17-40-9-40 14 0 24 40 42 40 42s40-18 40-42c0-23-31-31-40-14z" fill="#FFF8FF"/>
<path d="M140 103h138" stroke="#E6D8FF" stroke-width="5" stroke-linecap="round"/>
</g>
<path d="M68 122c35-34 68-34 96-8" stroke="#C6A7FF" stroke-width="8" stroke-linecap="round" opacity=".55"/>
<path d="M161 113l-16-4 8 16 8-12z" fill="#C6A7FF" opacity=".65"/>
<path d="M253 113c37-33 71-31 98 1" stroke="#C6A7FF" stroke-width="8" stroke-linecap="round" opacity=".55"/>
<path d="M348 114l-16-4 8 16 8-12z" fill="#C6A7FF" opacity=".65"/>
<ellipse cx="210" cy="226" rx="119" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="cup" x1="163" y1="44" x2="257" y2="169" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE7A8"/>
<stop offset=".52" stop-color="#F6B94B"/>
<stop offset="1" stop-color="#C87A2D"/>
</linearGradient>
<linearGradient id="base" x1="134" y1="150" x2="285" y2="224" gradientUnits="userSpaceOnUse">
<stop stop-color="#D8BFFF"/>
<stop offset="1" stop-color="#7956DB"/>
</linearGradient>
<filter id="softShadow" x="86" y="31" width="263" height="207" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#A77821" flood-opacity=".17"/>
</filter>
</defs>
<g opacity=".9">
<path d="M72 55l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM334 49l6 14 14 6-14 6-6 14-6-14-14-6 14-6 6-14zM333 161l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#F8C263"/>
<path d="M98 153l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM366 128l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#A77BFA"/>
</g>
<g filter="url(#softShadow)">
<path d="M141 77h-35c0 44 25 70 62 72" stroke="#F6B94B" stroke-width="15" stroke-linecap="round"/>
<path d="M279 77h35c0 44-25 70-62 72" stroke="#F6B94B" stroke-width="15" stroke-linecap="round"/>
<path d="M148 47h124l-13 80c-4 26-24 45-49 45s-45-19-49-45L148 47z" fill="url(#cup)" stroke="#C87A2D" stroke-width="6" stroke-linejoin="round"/>
<path d="M210 82l9 20 22 3-16 15 4 22-19-11-19 11 4-22-16-15 22-3 9-20z" fill="#FFF8FF" stroke="#FAD481" stroke-width="5" stroke-linejoin="round"/>
<path d="M196 168h28l8 32h-44l8-32z" fill="#C87A2D" stroke="#9E6427" stroke-width="5"/>
<rect x="145" y="190" width="130" height="37" rx="8" fill="url(#base)" stroke="#6D4EC8" stroke-width="5"/>
<rect x="171" y="181" width="78" height="19" rx="7" fill="#F8C263" stroke="#C87A2D" stroke-width="5"/>
<path d="M178 209h64" stroke="#F6D18C" stroke-width="6" stroke-linecap="round"/>
</g>
<path d="M96 207h228" stroke="#D7C4FF" stroke-width="12" stroke-linecap="round" opacity=".55"/>
<ellipse cx="210" cy="231" rx="121" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

+24 -7
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AppShellAccountModals from './AppShellAccountModals.vue'
import { privacyContentToHtml } from '../lib/privacyContent'
import { useAuthStore } from '../stores/auth'
import { useAwardsStore } from '../stores/awards'
import type { AuthRole } from '../types/awards'
@@ -22,6 +23,7 @@ const accountOpen = ref(false)
const deleteConfirm = ref(false)
const privacyOpen = ref(false)
const accountActionError = ref('')
const accountActionSuccess = ref('')
defineExpose({ privacyOpen })
@@ -36,12 +38,7 @@ const privacyContent = computed(
const privacyEmail = computed(
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
)
const privacyContentBlocks = computed(() =>
privacyContent.value
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyContentHtml = computed(() => privacyContentToHtml(privacyContent.value))
async function doLogin() {
try {
@@ -56,6 +53,21 @@ async function doLogout() {
await router.replace({ name: 'login' })
}
async function bindTeamTwitch() {
accountActionError.value = ''
accountActionSuccess.value = ''
try {
await authStore.startTwitchAuthorization({
purpose: 'team-binding',
returnUrl: route.fullPath,
})
} catch (error) {
accountActionError.value = error instanceof Error
? error.message
: 'Twitch-Login konnte nicht gestartet werden.'
}
}
async function deleteMyData() {
accountActionError.value = ''
try {
@@ -74,6 +86,8 @@ async function deleteMyData() {
function closeAccountModal() {
accountOpen.value = false
deleteConfirm.value = false
accountActionError.value = ''
accountActionSuccess.value = ''
}
function openPrivacyModal() {
@@ -101,6 +115,7 @@ function isActive(to: string) {
const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans-serif;font-size:14px;text-decoration:none;display:inline-block;transition:all .15s;'
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
</script>
<template>
@@ -175,9 +190,10 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
:privacy-open="privacyOpen"
:delete-confirm="deleteConfirm"
:session="authStore.session"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:privacy-email="privacyEmail"
:account-action-error="accountActionError"
:account-action-success="accountActionSuccess"
:auth-loading="authStore.loading"
@close-account="closeAccountModal"
@close-privacy="closePrivacyModal"
@@ -185,6 +201,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
@request-delete="requestAccountDeletion"
@cancel-delete="cancelAccountDeletion"
@logout="doLogout"
@bind-team-twitch="bindTeamTwitch"
@confirm-delete="deleteMyData"
/>
</template>
@@ -8,9 +8,10 @@ const props = defineProps<{
privacyOpen: boolean
deleteConfirm: boolean
session: AuthSession | null
privacyContentBlocks: string[]
privacyContentHtml: string
privacyEmail: string
accountActionError: string
accountActionSuccess: string
authLoading: boolean
}>()
@@ -21,11 +22,14 @@ defineEmits<{
'request-delete': []
'cancel-delete': []
logout: []
'bind-team-twitch': []
'confirm-delete': []
}>()
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
const role = computed(() => props.session?.role ?? 'viewer')
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
const canBindTwitch = computed(() => isTeamSession.value && !props.session?.mustChangePassword)
</script>
<template>
@@ -67,6 +71,14 @@ const role = computed(() => props.session?.role ?? 'viewer')
<span style="color:#6f6685;">Rolle</span>
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
</div>
<div v-if="session?.teamLogin" style="display:flex;justify-content:space-between;font-size:13.5px;">
<span style="color:#6f6685;">Team-Login</span>
<span style="font-weight:600;color:#3f3556;">@{{ session.teamLogin }}</span>
</div>
<div v-if="session?.boundTwitchUserId" style="display:flex;justify-content:space-between;font-size:13.5px;">
<span style="color:#6f6685;">Gebundenes Twitch</span>
<span style="font-weight:600;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
</div>
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
<span style="color:#6f6685;">Einreichungen</span>
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span>
@@ -77,6 +89,34 @@ const role = computed(() => props.session?.role ?? 'viewer')
</div>
</div>
</div>
<form
v-if="isTeamSession"
style="padding:16px;border-radius:14px;background:#f8f5ff;border:1px solid #ede4fb;display:grid;gap:12px;"
@submit.prevent="$emit('bind-team-twitch')"
>
<div>
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#8b6cdb;margin:0 0 4px;">Twitch verbinden</p>
<p style="font-size:12.5px;color:#6f6685;margin:0;line-height:1.45;">Verbinde deinen privaten Twitch-Account über den offiziellen Twitch Login. Danach kannst du dich im Admin-Panel per Twitch anmelden.</p>
</div>
<div v-if="session?.boundTwitchUserId" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border-radius:12px;background:white;border:1px solid #ede4fb;font-size:13px;">
<span style="color:#6f6685;">Aktuell verbunden</span>
<span style="font-weight:800;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
</div>
<p v-if="session?.mustChangePassword" style="font-size:12.5px;color:#b45309;margin:0;font-weight:700;">Bitte ändere zuerst dein temporäres Passwort.</p>
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0;font-weight:700;">{{ accountActionError }}</p>
<p v-if="accountActionSuccess" style="font-size:12.5px;color:#047857;margin:0;font-weight:700;">{{ accountActionSuccess }}</p>
<button
:disabled="authLoading || !canBindTwitch"
type="submit"
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 14px;border-radius:12px;border:none;background:#6f4fd1;color:white;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:800;cursor:pointer;disabled:opacity:.6;"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z" />
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z" />
</svg>
{{ authLoading ? 'Twitch wird geöffnet...' : session?.boundTwitchUserId ? 'Twitch neu verbinden' : 'Mit Twitch verbinden' }}
</button>
</form>
<button
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
@click="$emit('open-privacy')"
@@ -142,15 +182,9 @@ const role = computed(() => props.session?.role ?? 'viewer')
</div>
<button style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" @click="$emit('close-privacy')"></button>
</div>
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:18px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
<template v-if="privacyContentBlocks.length">
<p
v-for="(block, index) in privacyContentBlocks"
:key="`shell-privacy-${index}`"
style="margin:0;white-space:pre-wrap;"
>
{{ block }}
</p>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
<template v-if="privacyContentHtml">
<div class="app-shell-privacy-content" v-html="privacyContentHtml" />
</template>
<template v-else>
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
@@ -158,9 +192,31 @@ const role = computed(() => props.session?.role ?? 'viewer')
<p style="margin:0;">Die Datenschutzerklärung wird aus der Landingpage-Konfiguration geladen.</p>
</div>
</template>
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:0;">Kontakt: {{ privacyEmail }}</p>
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:18px 0 0;">Kontakt: {{ privacyEmail }}</p>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.app-shell-privacy-content :deep(p) {
margin: 0 0 14px;
}
.app-shell-privacy-content :deep(p:last-child),
.app-shell-privacy-content :deep(ul:last-child),
.app-shell-privacy-content :deep(ol:last-child) {
margin-bottom: 0;
}
.app-shell-privacy-content :deep(ul),
.app-shell-privacy-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.app-shell-privacy-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">{{ title }}</h3>
<p v-if="url" class="mt-2 break-all text-sm text-slate-500">{{ url }}</p>
</div>
<button
type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
aria-label="Preview schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div
v-if="contentHtml"
class="footer-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
open: boolean
title: string
url: string
contentHtml: string
}>()
defineEmits<{
close: []
}>()
</script>
<style scoped>
.footer-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.footer-preview-content :deep(p:last-child),
.footer-preview-content :deep(ul:last-child),
.footer-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.footer-preview-content :deep(ul),
.footer-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.footer-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -4,13 +4,13 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs und Inhalte werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Link2 class="h-6 w-6 text-violet-500" />
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Links speichern' }}
{{ saving ? 'Speichert ...' : 'Footer speichern' }}
</Button>
</div>
</div>
@@ -32,22 +32,73 @@
<input v-model="form.sponsorsUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
<div class="mt-7 space-y-5">
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'imprint')">
<Eye class="h-4 w-4" />
Impressum Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.imprintContent"
label="Impressum Inhalt"
placeholder="Impressumstext..."
min-height-class="min-h-[300px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'contact')">
<Eye class="h-4 w-4" />
Kontakt Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.contactContent"
label="Kontakt Inhalt"
placeholder="Kontakttext..."
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'sponsors')">
<Eye class="h-4 w-4" />
Sponsoren Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.sponsorsContent"
label="Sponsoren & Partner Inhalt"
placeholder="Sponsor:innen, Partner und Hinweise..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Link2, Save } from '@lucide/vue'
import { Eye, Link2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': [key: FooterPreviewKey]
}>()
function onSave() {
return props.saveSiteSettings('Footer & Kontakt')
}
@@ -21,15 +21,13 @@
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] space-y-4 overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<p
v-for="(block, index) in blocks"
:key="`privacy-modal-block-${index}`"
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
>
{{ block }}
</p>
<p v-if="!blocks.length" class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div
v-if="contentHtml"
class="privacy-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Noch kein Datenschutztext eingetragen.
</p>
</div>
@@ -43,7 +41,7 @@ import { X } from '@lucide/vue'
defineProps<{
open: boolean
blocks: string[]
contentHtml: string
updatedLabel: string
}>()
@@ -51,3 +49,25 @@ defineEmits<{
close: []
}>()
</script>
<style scoped>
.privacy-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.privacy-preview-content :deep(p:last-child),
.privacy-preview-content :deep(ul:last-child),
.privacy-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.privacy-preview-content :deep(ul),
.privacy-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.privacy-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -31,15 +31,13 @@
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
</div>
</div>
<label class="mt-6 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
<textarea
v-model="form.privacyPolicyContent"
rows="24"
class="w-full rounded-[28px] border border-violet-200 bg-[#fcfbff] px-5 py-4 text-sm leading-7 text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Datenschutztext..."
/>
</label>
<AdminRichTextEditor
v-model="form.privacyPolicyContent"
class="mt-6"
label="Datenschutz Inhalt"
placeholder="Datenschutztext..."
min-height-class="min-h-[560px]"
/>
</Card>
</section>
</template>
@@ -49,6 +47,7 @@ import { Eye, Save, ShieldCheck } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
import { ref } from 'vue'
import { KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import Modal from '../ui/Modal.vue'
import PasswordField from '../ui/PasswordField.vue'
defineProps<{
form: AdminOperationalSettingsForm
@@ -14,10 +17,16 @@ defineProps<{
error: string
success: string
demoPassword: string
twitchClientSecret: string
demoPasswordHint: string
demoPasswordSet: boolean
demoManagedByDatabase: boolean
demoCredentialsComplete: boolean
twitchSecretHint: string
twitchClientSecretSet: boolean
twitchAuthConfigured: boolean
twitchAuthManagedByDatabase: boolean
twitchAuthComplete: boolean
summary: AdminSettingsStatusSummary[]
dirty: boolean
canManage: boolean
@@ -26,10 +35,17 @@ defineProps<{
const emit = defineEmits<{
save: []
'update:demoPassword': [value: string]
'update:twitchClientSecret': [value: string]
}>()
function readInput(event: Event) {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
const twitchModalOpen = ref(false)
function defaultRedirectUri() {
if (typeof window === 'undefined') {
return 'https://deine-domain.de/api/auth/twitch/callback'
}
return `${window.location.origin.replace(/:\d+$/, ':5084')}/api/auth/twitch/callback`
}
function toneClasses(tone: AdminSettingsTone) {
@@ -89,7 +105,7 @@ function toneClasses(tone: AdminSettingsTone) {
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
</p>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-4">
<div
v-for="item in summary"
:key="item.label"
@@ -102,6 +118,46 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Settings class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Twitch OAuth</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Offiziellen Twitch Login konfigurieren</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Einmal mit Client-ID, Client Secret und Redirect URI verbinden. Danach verwenden Login und Account-Verknüpfung den offiziellen Twitch-Flow.
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full px-3 py-1" :class="twitchAuthConfigured ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchAuthConfigured ? 'OAuth bereit' : 'OAuth fehlt' }}
</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">
{{ twitchAuthManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}
</span>
<span class="rounded-full px-3 py-1" :class="twitchClientSecretSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchClientSecretSet ? 'Secret vorhanden' : 'Secret fehlt' }}
</span>
</div>
</div>
</div>
<Button
type="button"
variant="secondary"
class="gap-2 rounded-2xl px-5"
:disabled="loading || saving || !canManage"
@click="twitchModalOpen = true"
>
<LockKeyhole v-if="!canManage" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
{{ !canManage ? 'Nur Owner' : 'Twitch konfigurieren' }}
</Button>
</div>
</section>
<div class="grid gap-5 xl:grid-cols-2">
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@@ -140,7 +196,15 @@ function toneClasses(tone: AdminSettingsTone) {
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
<input :value="demoPassword" :disabled="saving || !canManage" type="password" autocomplete="new-password" placeholder="Leer lassen = behalten" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" @input="emit('update:demoPassword', readInput($event))" />
<PasswordField
:model-value="demoPassword"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
@update:model-value="emit('update:demoPassword', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
</label>
<label class="block">
@@ -207,4 +271,90 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
</Card>
<Modal
:open="twitchModalOpen"
title="Twitch OAuth"
subtitle="Client-Daten aus der Twitch Developer Console. Das Secret wird gespeichert, aber nie wieder angezeigt."
@close="twitchModalOpen = false"
>
<div class="space-y-5">
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
In Twitch muss dieselbe Redirect URI eingetragen sein, die hier gespeichert ist. Ohne eigene Redirect URI nutzt das Backend automatisch den Callback deiner API.
</div>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client-ID</span>
<input
v-model="form.twitchClientId"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Twitch Client-ID"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client Secret</span>
<PasswordField
:model-value="twitchClientSecret"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
@update:model-value="emit('update:twitchClientSecret', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ twitchSecretHint }}</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Redirect URI</span>
<input
v-model="form.twitchRedirectUri"
:disabled="saving || !canManage"
type="url"
autocomplete="off"
:placeholder="defaultRedirectUri()"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">Muss exakt in deiner Twitch-App unter OAuth Redirect URLs eingetragen sein.</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Scopes</span>
<input
v-model="form.twitchScope"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Optional, z.B. user:read:email"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">Für reines Login/Profil bleibt das Feld normalerweise leer.</span>
</label>
<p
v-if="form.twitchClientId && !twitchAuthComplete"
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
Zum Aktivieren fehlt noch ein Client Secret.
</p>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
<template #footer>
<Button type="button" variant="ghost" :disabled="saving" @click="twitchModalOpen = false">Schließen</Button>
<Button type="button" class="gap-2" :disabled="saving || !canManage" @click="emit('save')">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Twitch speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -38,7 +38,7 @@ const emit = defineEmits<{
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -126,7 +126,7 @@ const emit = defineEmits<{
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
<ul class="mt-2 space-y-1">
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.reviewNote || related.streamUrl || 'ohne Notiz' }}
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
</li>
</ul>
</div>
@@ -22,7 +22,7 @@ defineProps<{
<div v-for="nomination in reviewedNominations" :key="`reviewed-${nomination.id}`" class="rounded-2xl border border-violet-100 bg-white/90 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ nomination.candidateText }}</p>
<p class="font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festgelegt' }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
@@ -32,7 +32,7 @@ const emit = defineEmits<{
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -0,0 +1,254 @@
<template>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ label }}</span>
<div class="overflow-hidden rounded-[28px] border border-violet-200 bg-[#fcfbff] shadow-inner shadow-violet-100/50 transition focus-within:border-violet-400 focus-within:ring-4 focus-within:ring-violet-100">
<div class="flex flex-wrap items-center gap-2 border-b border-violet-100 bg-white/80 px-3 py-3">
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Fett" aria-label="Fett" @mousedown.prevent @click="runCommand('bold')">
<Bold class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Kursiv" aria-label="Kursiv" @mousedown.prevent @click="runCommand('italic')">
<Italic class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Unterstreichen" aria-label="Unterstreichen" @mousedown.prevent @click="runCommand('underline')">
<Underline class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Linksbündig" aria-label="Linksbündig" @mousedown.prevent @click="runCommand('justifyLeft')">
<AlignLeft class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Zentrieren" aria-label="Zentrieren" @mousedown.prevent @click="runCommand('justifyCenter')">
<AlignCenter class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Rechtsbündig" aria-label="Rechtsbündig" @mousedown.prevent @click="runCommand('justifyRight')">
<AlignRight class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Aufzählung" aria-label="Aufzählung" @mousedown.prevent @click="runCommand('insertUnorderedList')">
<List class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Nummerierte Liste" aria-label="Nummerierte Liste" @mousedown.prevent @click="runCommand('insertOrderedList')">
<ListOrdered class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Formatierung entfernen" aria-label="Formatierung entfernen" @mousedown.prevent @click="runCommand('removeFormat')">
<Eraser class="h-4 w-4" />
</button>
</div>
<label class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
<Type class="h-4 w-4 text-violet-500" />
<select v-model="selectedFont" class="bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applyFont">
<option v-for="font in fontOptions" :key="font" :value="font">{{ font }}</option>
</select>
</label>
<label class="rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
Größe
<select v-model="selectedSize" class="ml-2 bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applySize">
<option v-for="size in sizeOptions" :key="size.value" :value="size.value">{{ size.label }}</option>
</select>
</label>
</div>
<div
ref="editorRef"
class="rich-editor w-full px-5 py-4 text-sm leading-7 text-slate-700 outline-none"
:class="[minHeightClass, { 'rich-editor--empty': editorEmpty }]"
contenteditable="true"
role="textbox"
:aria-label="label"
:data-placeholder="placeholder"
@blur="handleEditorBlur"
@focus="isFocused = true"
@input="updateModelFromEditor"
@keyup="saveSelection"
@mouseup="saveSelection"
@paste="handlePaste"
/>
</div>
</label>
</template>
<script setup lang="ts">
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Italic,
List,
ListOrdered,
Type,
Underline,
} from '@lucide/vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { privacyContentToHtml, sanitizePrivacyHtml, stripPrivacyHtml } from '../../lib/privacyContent'
const props = withDefaults(defineProps<{
modelValue: string
label: string
placeholder: string
minHeightClass?: string
}>(), {
minHeightClass: 'min-h-[320px]',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorRef = ref<HTMLElement | null>(null)
const isFocused = ref(false)
const selectedFont = ref('Outfit')
const selectedSize = ref('3')
let savedSelection: Range | null = null
const fontOptions = ['Outfit', 'Inter', 'Arial', 'Georgia', 'Times New Roman', 'Verdana']
const sizeOptions = [
{ value: '2', label: '13 px' },
{ value: '3', label: '15 px' },
{ value: '4', label: '18 px' },
{ value: '5', label: '22 px' },
]
const editorEmpty = computed(() => !stripPrivacyHtml(props.modelValue))
watch(
() => props.modelValue,
(content) => {
if (!isFocused.value) {
syncEditorContent(content)
}
},
)
onMounted(() => syncEditorContent(props.modelValue))
function syncEditorContent(content: string) {
const editor = editorRef.value
if (!editor) {
return
}
const html = privacyContentToHtml(content)
if (editor.innerHTML !== html) {
editor.innerHTML = html
}
}
function updateModelFromEditor() {
const editor = editorRef.value
if (!editor) {
return
}
saveSelection()
emit('update:modelValue', sanitizePrivacyHtml(editor.innerHTML))
}
function saveSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection?.rangeCount) {
return
}
const range = selection.getRangeAt(0)
const selectionInsideEditor = editor.contains(range.commonAncestorContainer)
|| editor === range.commonAncestorContainer
if (selectionInsideEditor) {
savedSelection = range.cloneRange()
}
}
function restoreSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection || !savedSelection) {
return
}
const selectionInsideEditor = editor.contains(savedSelection.commonAncestorContainer)
|| editor === savedSelection.commonAncestorContainer
if (!selectionInsideEditor) {
return
}
selection.removeAllRanges()
selection.addRange(savedSelection)
}
function handleEditorBlur() {
saveSelection()
isFocused.value = false
}
async function focusEditor() {
await nextTick()
editorRef.value?.focus()
restoreSelection()
}
async function runCommand(command: string, value?: string) {
await focusEditor()
document.execCommand(command, false, value)
updateModelFromEditor()
}
function applyFont() {
return runCommand('fontName', selectedFont.value)
}
function applySize() {
return runCommand('fontSize', selectedSize.value)
}
function handlePaste(event: ClipboardEvent) {
event.preventDefault()
const html = event.clipboardData?.getData('text/html')
const text = event.clipboardData?.getData('text/plain') ?? ''
const safeHtml = html ? sanitizePrivacyHtml(html) : privacyContentToHtml(text)
document.execCommand('insertHTML', false, safeHtml)
updateModelFromEditor()
}
</script>
<style scoped>
.rich-editor-tool {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
color: #6d5a86;
transition:
background-color 160ms ease,
color 160ms ease;
}
.rich-editor-tool:hover {
background: #f3e8ff;
color: #7c3aed;
}
.rich-editor :deep(p) {
margin: 0 0 0.85rem;
}
.rich-editor :deep(ul),
.rich-editor :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.rich-editor :deep(li) {
margin: 0.2rem 0;
}
.rich-editor--empty::before {
content: attr(data-placeholder);
color: #a9a1b8;
pointer-events: none;
}
</style>
@@ -69,11 +69,11 @@ const props = defineProps<{
<input v-model="props.createForm.votingEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review startet</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung startet</span>
<input v-model="props.createForm.reviewStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review endet</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung endet</span>
<input v-model="props.createForm.reviewEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 sm:col-span-2">
@@ -118,7 +118,7 @@ const phaseCards = computed(() =>
...phase,
ordinal: String(index + 1).padStart(2, '0'),
finalLocked,
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Phase aktivieren',
actionTitle: isCompletedPhase
? showHasPassed
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
@@ -46,7 +46,7 @@
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ canActivatePublic || form.isCurrent ? 'Nur ein Jahr sollte öffentlich sichtbar sein.' : 'Erst die Readiness-Blocker unten loesen.' }}
{{ form.isCurrent ? 'Dieses Jahr ist auf der Landingpage sichtbar.' : canActivatePublic ? 'Mit Speichern oder Button als Landingpage-Jahr aktivieren.' : 'Erst die Public-Readiness-Blocker unten loesen.' }}
</span>
</span>
</label>
@@ -55,11 +55,15 @@
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">{{ adminError }}</p>
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-3">
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-4">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
Löschen
</Button>
<Button variant="ghost" class="w-full gap-2 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="saving || !selectedSeasonId || form.isCurrent || !canActivatePublic" @click="activatePublicSeason">
<Globe2 class="h-4 w-4" />
{{ saving ? 'Aktiviert ...' : 'Public aktivieren' }}
</Button>
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
<CheckCircle2 class="h-4 w-4" />
{{ completing ? 'Schliesst ...' : 'Beenden' }}
@@ -130,7 +134,7 @@
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
import { AlertTriangle, CheckCircle2, Globe2, History, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
@@ -155,6 +159,7 @@ defineProps<{
canCompleteSelectedSeason: boolean
selectedSeasonIsCurrent: boolean
openDeleteSeasonModal: () => void
activatePublicSeason: () => Promise<boolean | void> | boolean | void
saveSeason: () => Promise<boolean | void> | boolean | void
completeSeason: () => Promise<void>
}>()
@@ -4,9 +4,9 @@
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen bearbeiten</h2>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen und Pausen bearbeiten</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Änderungen werden direkt im gewählten Award-Jahr gespeichert.
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Die Aufbereitung ist die planbare Pause zwischen Voting und Show.
</p>
</div>
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
@@ -89,7 +89,7 @@
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
<p class="text-xs leading-5 text-slate-500">
Hinweis: Die Phasen sind als Systemphasen fest verdrahtet, damit Nominierung, Voting, Review und Show sicher mit Public-API und Rate-Limits zusammenspielen.
Hinweis: Nominierung, Voting, Aufbereitung und Show sind als Systemfenster verdrahtet, damit Public-API, Vorschau und Teilnahme-Gates sauber zusammenlaufen.
</p>
</div>
</Card>
@@ -19,8 +19,11 @@ export type AdminContentForm = {
privacyEmail: string
privacyPolicyContent: string
imprintUrl: string
imprintContent: string
contactUrl: string
contactContent: string
sponsorsUrl: string
sponsorsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -1,4 +1,4 @@
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type PhaseKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type SeasonDateField =
| 'nominationStartsAt'
@@ -58,9 +58,9 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
editable: true,
},
{
key: 'review',
title: 'Review & Auswertung',
description: 'Team prüft Votes, Clips und Ergebnisse.',
key: 'preparation',
title: 'Aufbereitung',
description: 'Pause vor der Show: Ergebnisse, Clips und Ablauf vorbereiten.',
start: 'reviewStartsAt',
end: 'reviewEndsAt',
editable: true,
@@ -116,7 +116,7 @@ export function normalizePhaseKey(value: string): PhaseKey | string {
const phase = value.trim().toLowerCase()
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
if (phase.includes('show')) return 'show'
if (phase.includes('review') || phase.includes('auswert')) return 'review'
if (phase.includes('aufbereit') || phase.includes('vorbereit') || phase.includes('pause') || phase.includes('review') || phase.includes('auswert')) return 'preparation'
if (phase.includes('vot')) return 'voting'
if (phase.includes('nomin')) return 'nomination'
return phase
@@ -7,6 +7,9 @@ export interface AdminOperationalSettingsForm {
demoLoginIdentifier: string
demoLoginTwitchUserId: string
demoLoginDisplayName: string
twitchClientId: string
twitchRedirectUri: string
twitchScope: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
@@ -5,6 +5,7 @@ import {
simpleIconForKey,
socialIconOptionForKey,
} from '../../lib/socialIcons'
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
@@ -16,8 +17,11 @@ function createEmptyForm(): AdminContentForm {
privacyEmail: '',
privacyPolicyContent: '',
imprintUrl: '',
imprintContent: '',
contactUrl: '',
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
socialLinks: [],
faq: [],
}
@@ -74,8 +78,11 @@ export function useAdminContentManager() {
form.privacyEmail = settings.privacyEmail
form.privacyPolicyContent = settings.privacyPolicyContent
form.imprintUrl = settings.imprintUrl
form.imprintContent = settings.imprintContent
form.contactUrl = settings.contactUrl
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -92,12 +99,7 @@ export function useAdminContentManager() {
{ immediate: true, deep: true },
)
const privacyPreviewBlocks = computed(() =>
form.privacyPolicyContent
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyPreviewHtml = computed(() => privacyContentToHtml(form.privacyPolicyContent))
const privacyUpdatedLabel = computed(() => {
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
@@ -241,10 +243,13 @@ export function useAdminContentManager() {
hostTagline: form.hostTagline,
newsletterUrl: form.newsletterUrl,
privacyEmail: form.privacyEmail,
privacyPolicyContent: form.privacyPolicyContent,
privacyPolicyContent: privacyContentForStorage(form.privacyPolicyContent),
imprintUrl: form.imprintUrl,
imprintContent: privacyContentForStorage(form.imprintContent),
contactUrl: form.contactUrl,
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -277,7 +282,7 @@ export function useAdminContentManager() {
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
@@ -3,6 +3,7 @@ import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/v
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const metricToneMap = {
Nominierungen: {
@@ -29,6 +30,7 @@ const metricToneMap = {
export function useAdminDashboardOverview() {
const store = useAwardsStore()
const authStore = useAuthStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
@@ -108,6 +110,17 @@ export function useAdminDashboardOverview() {
icon: ShieldAlert,
},
])
function canOpenAdminPath(path: string) {
if (path.startsWith('/admin/nominations')) return authStore.hasPermission('nominations')
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
if (path.startsWith('/admin/analytics')) return authStore.hasPermission('analytics')
return true
}
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
@@ -141,7 +154,7 @@ export function useAdminDashboardOverview() {
icon: Users,
tone: 'emerald',
},
])
].filter((item) => canOpenAdminPath(item.to)))
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
@@ -172,7 +185,7 @@ export function useAdminDashboardOverview() {
state: openRiskCount.value === 0 ? 'ok' : 'danger',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
].filter((item) => canOpenAdminPath(item.to))
})
return {
@@ -16,6 +16,10 @@ export function useAdminOperationalSettings() {
const demoPasswordSet = ref(false)
const demoManagedByDatabase = ref(false)
const demoPasswordInput = ref('')
const twitchClientSecretSet = ref(false)
const twitchAuthConfigured = ref(false)
const twitchAuthManagedByDatabase = ref(false)
const twitchClientSecretInput = ref('')
const savedOperationalSnapshot = ref('')
const operationalForm = reactive<AdminOperationalSettingsForm>({
@@ -23,6 +27,9 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: '',
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
twitchClientId: '',
twitchRedirectUri: '',
twitchScope: '',
maintenanceModeEnabled: false,
maintenanceTitle: fallbackMaintenanceTitle,
maintenanceMessage: fallbackMaintenanceMessage,
@@ -52,6 +59,23 @@ export function useAdminOperationalSettings() {
),
)
const twitchAuthComplete = computed(() =>
Boolean(operationalForm.twitchClientId.trim())
&& (twitchClientSecretSet.value || Boolean(twitchClientSecretInput.value.trim()))
)
const twitchSecretHint = computed(() => {
if (twitchClientSecretInput.value.trim()) {
return 'Dieses neue Secret wird beim Speichern gesetzt.'
}
if (twitchClientSecretSet.value) {
return 'Client Secret ist gesetzt. Leer lassen, wenn es bleiben soll.'
}
return 'Noch kein Client Secret gesetzt. Beim ersten Speichern erforderlich.'
})
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Demo Login',
@@ -73,6 +97,14 @@ export function useAdminOperationalSettings() {
: 'Öffentliche Seiten werden normal ausgeliefert.',
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
},
{
label: 'Twitch OAuth',
value: twitchAuthConfigured.value ? 'Konfiguriert' : 'Fehlt',
note: twitchAuthConfigured.value
? twitchAuthManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration'
: 'Offizieller Twitch Login ist noch nicht aktiv.',
tone: twitchAuthConfigured.value ? 'good' : 'warning',
},
{
label: 'Passwort',
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
@@ -91,12 +123,19 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier = response.demoLoginEmail
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
operationalForm.twitchClientId = response.twitchClientId
operationalForm.twitchRedirectUri = response.twitchRedirectUri
operationalForm.twitchScope = response.twitchScope
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
operationalForm.maintenanceTitle = response.maintenanceTitle
operationalForm.maintenanceMessage = response.maintenanceMessage
demoPasswordSet.value = response.demoLoginPasswordSet
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
twitchClientSecretSet.value = response.twitchClientSecretSet
twitchAuthConfigured.value = response.twitchAuthConfigured
twitchAuthManagedByDatabase.value = response.twitchAuthManagedByDatabase
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
rememberSavedOperationalSettings()
}
@@ -106,6 +145,10 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
twitchClientId: operationalForm.twitchClientId,
twitchRedirectUri: operationalForm.twitchRedirectUri,
twitchScope: operationalForm.twitchScope,
twitchClientSecretInput: twitchClientSecretInput.value,
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
maintenanceTitle: operationalForm.maintenanceTitle,
maintenanceMessage: operationalForm.maintenanceMessage,
@@ -137,8 +180,8 @@ export function useAdminOperationalSettings() {
}
function validateOperationalSettings() {
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 10) {
operationalError.value = 'Das Demo-Passwort muss mindestens 10 Zeichen lang sein.'
return false
}
@@ -147,6 +190,24 @@ export function useAdminOperationalSettings() {
return false
}
const hasAnyTwitchAuthInput = Boolean(
operationalForm.twitchClientId.trim()
|| operationalForm.twitchRedirectUri.trim()
|| operationalForm.twitchScope.trim()
|| twitchClientSecretInput.value.trim()
|| twitchClientSecretSet.value,
)
if (hasAnyTwitchAuthInput && !operationalForm.twitchClientId.trim()) {
operationalError.value = 'Twitch OAuth braucht eine Client-ID.'
return false
}
if (operationalForm.twitchClientId.trim() && !twitchAuthComplete.value) {
operationalError.value = 'Twitch OAuth braucht beim ersten Speichern ein Client Secret.'
return false
}
return true
}
@@ -164,11 +225,14 @@ export function useAdminOperationalSettings() {
...operationalForm,
demoLoginEmail: operationalForm.demoLoginIdentifier,
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
twitchClientSecret: twitchClientSecretInput.value.trim() || undefined,
})
demoPasswordSet.value = result.demoLoginPasswordSet
demoManagedByDatabase.value = true
twitchClientSecretSet.value = result.twitchClientSecretSet
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
await loadOperationalSettings({ silent: true })
clearSiteStatusCache()
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
@@ -187,6 +251,10 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier,
operationalForm.demoLoginTwitchUserId,
operationalForm.demoLoginDisplayName,
operationalForm.twitchClientId,
operationalForm.twitchRedirectUri,
operationalForm.twitchScope,
twitchClientSecretInput.value,
operationalForm.maintenanceModeEnabled,
operationalForm.maintenanceTitle,
operationalForm.maintenanceMessage,
@@ -210,8 +278,14 @@ export function useAdminOperationalSettings() {
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
twitchClientSecretSet,
twitchAuthConfigured,
twitchAuthManagedByDatabase,
twitchClientSecretInput,
demoPasswordHint,
twitchSecretHint,
demoCredentialsComplete,
twitchAuthComplete,
operationalSummary,
hasUnsavedOperationalChanges,
loadOperationalSettings,
@@ -27,7 +27,7 @@ export function useAdminReviewsManager() {
const query = reviewFilter.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) =>
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, extractNominationStreamUrl(nomination)]
.join(' ')
.toLowerCase()
.includes(query)),
@@ -74,7 +74,7 @@ export function useAdminReviewsManager() {
return false
}
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
const sameName = Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
return sameName || sameStreamUrl
})
@@ -84,9 +84,13 @@ export function useAdminReviewsManager() {
if (!selectedNomination.value) return null
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
nomination.categoryId === selectedNomination.value?.categoryId &&
nomination.candidateText.trim().toLowerCase() === selectedName,
(
(Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName) ||
(Boolean(selectedStreamUrl) && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl)
),
)
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
const platforms = new Set(
@@ -72,7 +72,7 @@ export function useAdminSeasonManager() {
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
const phasePresets = ['Nominierung', 'Community Voting', 'Aufbereitung', 'Award Show', 'Abgeschlossen']
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
const publicReadinessIssues = computed(() =>
readinessItems.value
@@ -80,6 +80,9 @@ export function useAdminSeasonManager() {
.map((item) => item.note),
)
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
const visibleArchiveReadinessIssues = computed(() =>
normalizePhaseKey(form.currentPhase) === 'completed' ? archiveReadinessIssues.value : [],
)
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
const canCompleteSelectedSeason = computed(() =>
@@ -173,7 +176,7 @@ export function useAdminSeasonManager() {
createForm.votingStartsAt = `${year}-08-25`
createForm.votingEndsAt = `${year}-09-11`
createForm.reviewStartsAt = `${year}-09-12`
createForm.reviewEndsAt = `${year}-09-15`
createForm.reviewEndsAt = `${year}-09-19`
createForm.showDate = `${year}-09-20`
createForm.showStartsAt = '20:00'
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
@@ -229,6 +232,19 @@ export function useAdminSeasonManager() {
return persistSeason('Jahresstatus gespeichert.')
}
async function activatePublicSeason() {
if (!selectedSeasonId.value || saving.value || form.isCurrent) {
return false
}
form.isCurrent = true
const saved = await persistSeason(`Award-Jahr ${form.year} ist jetzt auf der Landingpage aktiv.`)
if (!saved) {
form.isCurrent = false
}
return saved
}
async function activatePhase(phase: string) {
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
return
@@ -392,7 +408,7 @@ export function useAdminSeasonManager() {
selectedSeason,
readinessItems,
publicReadinessIssues,
archiveReadinessIssues,
archiveReadinessIssues: visibleArchiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
@@ -404,6 +420,7 @@ export function useAdminSeasonManager() {
latestSeasonAuditMeta,
canCreate,
activatePhase,
activatePublicSeason,
openCreateModal,
saveSeason,
completeSeason,
@@ -477,7 +494,7 @@ function buildReadinessItems(
label: 'Reviews',
note: detail.pendingNominations.length === 0
? 'Keine offenen Nominierungsreviews.'
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
: `${detail.pendingNominations.length} Reviews sollten vor dem Voting-Freeze entschieden werden.`,
complete: detail.pendingNominations.length === 0,
blocking: false,
to: '/admin/nominations?review=1',
@@ -31,6 +31,11 @@ export function useAdminSettingsOverview() {
siteSettings.value.sponsorsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
label: 'Host',
@@ -57,8 +62,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4,
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -0,0 +1,276 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import type { AdminTeamMember, AdminTeamPermission, AdminTeamRole } from '../../types/awards'
export interface TeamMemberForm {
login: string
displayName: string
role: string
isActive: boolean
}
const emptyMemberForm = (): TeamMemberForm => ({
login: '',
displayName: '',
role: 'member',
isActive: true,
})
export function useAdminTeamManager() {
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const generatedPassword = ref('')
const generatedPasswordLogin = ref('')
const confirmDeleteMemberId = ref<number | null>(null)
const members = ref<AdminTeamMember[]>([])
const roles = ref<AdminTeamRole[]>([])
const permissions = ref<AdminTeamPermission[]>([])
const editingMemberId = ref<number | null>(null)
const rolePermissionDrafts = ref<Record<string, string[]>>({})
const savedRoleSnapshot = ref('')
const memberForm = reactive<TeamMemberForm>(emptyMemberForm())
const activeMembers = computed(() => members.value.filter((member) => member.isActive).length)
const pendingPasswordChanges = computed(() => members.value.filter((member) => member.mustChangePassword).length)
const roleOptions = computed(() => roles.value.map((role) => ({ value: role.key, label: role.label })))
const selectedMember = computed(() => members.value.find((member) => member.id === editingMemberId.value) ?? null)
const hasRoleChanges = computed(() =>
Boolean(savedRoleSnapshot.value) && JSON.stringify(rolePermissionDrafts.value) !== savedRoleSnapshot.value,
)
function resetMessages() {
errorMessage.value = ''
successMessage.value = ''
}
function applyTeamResponse(response: { members: AdminTeamMember[]; roles: AdminTeamRole[]; permissions: AdminTeamPermission[] }) {
members.value = response.members
roles.value = response.roles
permissions.value = response.permissions
rolePermissionDrafts.value = Object.fromEntries(
response.roles.map((role) => [role.key, [...role.permissionKeys]]),
)
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
}
async function loadTeam() {
loading.value = true
resetMessages()
try {
applyTeamResponse(await api.getAdminTeam())
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
function startCreateMember() {
editingMemberId.value = null
Object.assign(memberForm, emptyMemberForm())
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function startEditMember(member: AdminTeamMember) {
editingMemberId.value = member.id
memberForm.login = member.login
memberForm.displayName = member.displayName
memberForm.role = member.role
memberForm.isActive = member.isActive
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function validateMemberForm() {
if (!memberForm.login.trim() && editingMemberId.value === null) {
errorMessage.value = 'Bitte gib einen Login an.'
return false
}
if (!memberForm.displayName.trim()) {
errorMessage.value = 'Bitte gib einen Anzeigenamen an.'
return false
}
return true
}
async function saveMember() {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
if (!validateMemberForm()) return false
saving.value = true
try {
if (editingMemberId.value === null) {
const result = await api.createAdminTeamMember({
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
})
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = memberForm.login
successMessage.value = 'Team-Login wurde erstellt. Das temporäre Passwort ist nur jetzt sichtbar.'
} else {
await api.updateAdminTeamMember(editingMemberId.value, {
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
isActive: memberForm.isActive,
})
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gespeichert.'
}
return true
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gespeichert werden.'
return false
} finally {
saving.value = false
}
}
async function resetMemberPassword(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
const result = await api.resetAdminTeamMemberPassword(member.id)
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = member.login
successMessage.value = 'Passwort wurde zurückgesetzt. Das Mitglied muss es beim nächsten Login ändern.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Passwort konnte nicht zurückgesetzt werden.'
} finally {
saving.value = false
}
}
function requestDeleteMember(member: AdminTeamMember) {
confirmDeleteMemberId.value = member.id
resetMessages()
}
function cancelDeleteMember() {
confirmDeleteMemberId.value = null
}
async function deleteMember(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
await api.deleteAdminTeamMember(member.id)
confirmDeleteMemberId.value = null
if (editingMemberId.value === member.id) {
startCreateMember()
}
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gelöscht.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gelöscht werden.'
} finally {
saving.value = false
}
}
function roleHasPermission(roleKey: string, permissionKey: string) {
return rolePermissionDrafts.value[roleKey]?.includes(permissionKey) ?? false
}
function setRolePermission(roleKey: string, permissionKey: string, checked: boolean) {
if (roleKey === 'owner') return
const current = new Set(rolePermissionDrafts.value[roleKey] ?? [])
if (checked) {
current.add(permissionKey)
} else {
current.delete(permissionKey)
}
rolePermissionDrafts.value = {
...rolePermissionDrafts.value,
[roleKey]: [...current].sort(),
}
}
async function saveRolePermissions() {
resetMessages()
saving.value = true
try {
const result = await api.updateAdminTeamRoles({
roles: roles.value.map((role) => ({
key: role.key,
permissionKeys: rolePermissionDrafts.value[role.key] ?? [],
})),
})
roles.value = result.roles
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
successMessage.value = 'Rollen und Berechtigungen wurden gespeichert.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Berechtigungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
onMounted(loadTeam)
return {
loading,
saving,
errorMessage,
successMessage,
generatedPassword,
generatedPasswordLogin,
confirmDeleteMemberId,
members,
roles,
permissions,
memberForm,
editingMemberId,
selectedMember,
activeMembers,
pendingPasswordChanges,
roleOptions,
hasRoleChanges,
loadTeam,
startCreateMember,
startEditMember,
saveMember,
resetMemberPassword,
requestDeleteMember,
cancelDeleteMember,
deleteMember,
roleHasPermission,
setRolePermission,
saveRolePermissions,
}
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
const props = defineProps<{
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -32,14 +32,11 @@ const props = defineProps<{
</div>
<button @click="props.onClosePrivacy" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;"></button>
</div>
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:14px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<p
v-for="(block, index) in props.privacyContentBlocks"
:key="`privacy-block-${index}`"
style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;white-space:pre-wrap;"
>
{{ block }}
</p>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
Datenschutzerklärung wird geladen.
</div>
</div>
</div>
</div>
@@ -99,3 +96,25 @@ const props = defineProps<{
</div>
</template>
</template>
<style scoped>
.home-privacy-content :deep(p) {
margin: 0 0 14px;
}
.home-privacy-content :deep(p:last-child),
.home-privacy-content :deep(ul:last-child),
.home-privacy-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-privacy-content :deep(ul),
.home-privacy-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-privacy-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -37,7 +37,7 @@ const props = defineProps<{
:style="props.archiveYearButtonStyle(year.active)"
>
<span>{{ year.label }}</span>
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winners.length }}</span>
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winnerCount }}</span>
</button>
</div>
</aside>
@@ -46,7 +46,7 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomValue = ref(0)
const clipNomQuery = ref('')
watch(
() => props.catOptions,
@@ -61,21 +61,13 @@ watch(
{ immediate: true },
)
watch(
() => props.clipNomOptions,
(options) => {
clipNomValue.value = resolveOptionValue(clipNomValue.value, options)
},
{ immediate: true },
)
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
}
function handleClipCategoryChange(value: number) {
clipCatValue.value = value
clipNomValue.value = 0
clipNomQuery.value = ''
notifyClipCategoryChange(value)
}
@@ -134,7 +126,7 @@ function notifyClipCategoryChange(value: number) {
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
<div>
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Name und Stream-Link gehen direkt in den Admin-Review.</p>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
@@ -145,14 +137,10 @@ function notifyClipCategoryChange(value: number) {
:options="props.catOptions"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Name <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationNameRef" type="text" placeholder="Kanalname oder Anzeigename" maxlength="120" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://twitch.tv/kanal oder https://kick.com/kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Twitch, Kick, YouTube oder ein anderer offizieller Kanal-Link.</p>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
</div>
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
@@ -166,7 +154,7 @@ function notifyClipCategoryChange(value: number) {
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
@@ -180,13 +168,19 @@ function notifyClipCategoryChange(value: number) {
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
style-focus="border-color:#8b6cdb;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
@@ -235,7 +229,7 @@ function notifyClipCategoryChange(value: number) {
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
</div>
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div>
@@ -249,13 +243,19 @@ function notifyClipCategoryChange(value: number) {
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
<HomeSelectDropdown
v-model="clipNomValue"
data-ref="clipNomRef"
label="VTuber fuer Clip auswaehlen"
placeholder="Noch keine Kandidat:innen"
:options="props.clipNomOptions"
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
style-focus="border-color:#8b6cdb;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
@@ -26,12 +26,12 @@ const {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -145,7 +145,7 @@ const {
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'review', label: 'Review', hint: 'Auswertung' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
@@ -162,7 +162,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -250,7 +250,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
<HomeTimelineSection
:nomination-phase="nominationPhase"
:voting-phase="votingPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:show-phase="showPhase"
:completed-phase="completedPhase"
:timeline-line-style="timelineLineStyle"
@@ -278,7 +278,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:section-action-disabled="sectionActionDisabled"
:section-action-style="sectionActionStyle"
:nomination-phase="nominationPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:on-section-action="onSectionAction"
/>
@@ -342,7 +342,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -58,7 +58,7 @@ defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -132,7 +132,7 @@ defineProps<{
<HomeAccountAndPrivacyModals
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -7,7 +7,7 @@ const props = defineProps<{
sectionActionDisabled: boolean
sectionActionStyle: string
nominationPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
onSectionAction: (event?: Event) => void
}>()
</script>
@@ -72,7 +72,7 @@ const props = defineProps<{
:style="props.sectionActionStyle"
style-hover="transform:translateY(-2px);"
>
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.reviewPhase ? '#B7791F' : '#9146FF'"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.preparationPhase ? '#B7791F' : '#9146FF'"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
</a>
</div>
</section>
@@ -1,4 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
interface HomeSocialLink {
label: string
platform: string
@@ -7,8 +11,10 @@ interface HomeSocialLink {
}
interface FooterLink {
key: string
label: string
url: string
content: string
}
interface SiteContent {
@@ -31,6 +37,25 @@ const props = defineProps<{
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
const activeFooterLink = ref<FooterLink | null>(null)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
function openFooterLink(event: Event, link: FooterLink) {
if (!link.content.trim()) {
if (!link.url.trim()) {
event.preventDefault()
}
return
}
event.preventDefault()
activeFooterLink.value = link
}
function closeFooterLink() {
activeFooterLink.value = null
}
</script>
<template>
@@ -111,10 +136,83 @@ const props = defineProps<{
VTuber Star Award 2026
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
<a v-for="link in props.footerLinks" :key="link.label" :href="link.url" target="_blank" rel="noopener" style="color:var(--muted,#8a8398);text-decoration:none;" style-hover="color:var(--accent,#8b6cdb);">{{ link.label }}</a>
<template v-for="link in props.footerLinks" :key="link.key || link.label">
<button
v-if="link.content"
type="button"
style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:inherit;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</button>
<a
v-else
:href="link.url || '#'"
target="_blank"
rel="noopener"
style="color:var(--muted,#8a8398);text-decoration:none;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</a>
</template>
<button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;" style-hover="color:var(--accent,#8b6cdb);">Datenschutz</button>
</div>
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with &amp; Chaos</div>
</div>
</footer>
<template v-if="activeFooterLink">
<div class="home-modal-overlay" @click="closeFooterLink" style="position:fixed;inset:0;z-index:420;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
<div class="home-modal" @click.stop style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
</div>
<button @click="closeFooterLink" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;"></button>
</div>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="activeFooterHtml" class="home-footer-page-content" v-html="activeFooterHtml" />
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
<a
v-if="activeFooterLink.url"
:href="activeFooterLink.url"
target="_blank"
rel="noopener"
style="display:inline-flex;align-items:center;gap:9px;margin-top:18px;padding:12px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #e6dcf6;color:#6a4fb8;text-decoration:none;font-weight:700;"
style-hover="background:#f1ecfb;"
>
Externe Seite öffnen
</a>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.home-footer-page-content :deep(p) {
margin: 0 0 14px;
}
.home-footer-page-content :deep(p:last-child),
.home-footer-page-content :deep(ul:last-child),
.home-footer-page-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-footer-page-content :deep(ul),
.home-footer-page-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-footer-page-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -2,8 +2,8 @@
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
<div style="text-align:center;margin-bottom:56px;">
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;"> Der Ablauf</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Phasen auf die Bühne</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über Review &amp; Auswertung bis ganz zum Schluss zur grossen Show.</p>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Schritten auf die Bühne</h2>
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.</p>
</div>
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
@@ -58,29 +58,29 @@
</div>
<div style="position:relative;z-index:1;text-align:center;">
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : reviewPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : preparationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
<template v-if="nominationPhase || votingPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</template>
<template v-else-if="reviewPhase">
<template v-else-if="preparationPhase">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</template>
<template v-else>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</template>
</div>
<div :style="reviewPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
<div :style="reviewPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ reviewPhase ? 'IN PRÜFUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Review &amp; Auswertung</h3>
<div :style="reviewPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('review') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team prüft Fairness, Stimmen und Clips, wertet die Ergebnisse aus und bereitet die Show final vor.</p>
<div :style="preparationPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
<div :style="preparationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ preparationPhase ? 'AUFBEREITUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Aufbereitung</h3>
<div :style="preparationPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('preparation') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.</p>
<button
type="button"
disabled
aria-disabled="true"
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
:style="preparationButtonStyle(preparationPhase, showPhase, completedPhase)"
>
{{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
{{ preparationPhase ? 'Aufbereitung läuft' : showPhase || completedPhase ? 'Aufbereitung abgeschlossen' : 'Aufbereitung folgt' }}
</button>
</div>
</div>
@@ -131,12 +131,12 @@
defineProps<{
nominationPhase: boolean
votingPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
showPhase: boolean
completedPhase: boolean
timelineLineStyle: string
publicStreamUrl: string
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
formatTimelineRange: (key: 'nomination' | 'voting' | 'preparation' | 'show') => string
openNominate: (event?: Event) => void
openVote: (event?: Event) => void
onTimelineFinalAction: (event?: Event) => void
@@ -158,8 +158,8 @@ function voteButtonStyle(votingPhase: boolean) {
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:none;"
}
function reviewButtonStyle(reviewPhase: boolean, showPhase: boolean, completedPhase: boolean) {
if (reviewPhase) {
function preparationButtonStyle(preparationPhase: boolean, showPhase: boolean, completedPhase: boolean) {
if (preparationPhase) {
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#fff1d6;color:#8a5a00;border:1px solid #f3ddae;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
}
@@ -2,19 +2,18 @@ import type { CandidateSummary } from '../../types/awards'
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type HomePreviewPhase = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
export interface HomeClipSubmitContext {
clipUrl: string
selectedNomineeIndex: number
selectedNomineeQuery: string
description: string
}
export interface HomeNominationSubmitContext {
categoryIndex: number
name: string
streamUrl: string
}
@@ -37,6 +37,7 @@ export interface HomeSelectionOption {
export interface HomeArchiveYearItem {
year: number
label: string
winnerCount: number
winners: Array<unknown>
active: boolean
}
@@ -6,16 +6,30 @@ type AwardsStore = ReturnType<typeof useAwardsStore>
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
const archiveYears = computed(() => {
const knownYears = new Set<number>(store.overview.winnersPreview.map((entry) => entry.year))
if (store.archive.items.length > 0) {
knownYears.add(store.archive.year)
const knownYears = new Map<number, number>()
for (const entry of store.overview.archiveYears ?? []) {
knownYears.set(entry.year, entry.winnerCount)
}
return [...knownYears]
.sort((left, right) => right - left)
.map((year) => ({
for (const entry of store.overview.winnersPreview) {
if (!knownYears.has(entry.year)) {
knownYears.set(
entry.year,
store.overview.winnersPreview.filter((winner) => winner.year === entry.year).length,
)
}
}
if (store.archive.items.length > 0) {
knownYears.set(store.archive.year, store.archive.items.length)
}
return [...knownYears.entries()]
.sort(([left], [right]) => right - left)
.map(([year, winnerCount]) => ({
year,
label: String(year),
winnerCount: year === store.archive.year ? store.archive.items.length : winnerCount,
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
active: archiveYear.value === year,
}))
@@ -7,7 +7,7 @@ import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandin
type AwardsStore = ReturnType<typeof useAwardsStore>
type AuthStore = ReturnType<typeof useAuthStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
@@ -40,8 +40,10 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
)
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
(store.overview.archiveYears ?? []).length > 0
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
: store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
: [{ year: store.overview.year - 1 }],
)
@@ -46,7 +46,7 @@ export function useHomeLandingState() {
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
privacyContentHtml,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
@@ -101,7 +101,7 @@ export function useHomeLandingState() {
const {
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -257,13 +257,13 @@ export function useHomeLandingState() {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
candidateCount,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -7,7 +7,7 @@ import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
interface PhaseCountdownTarget {
label: string
@@ -35,11 +35,11 @@ interface UseHomeLandingViewEffectsParams {
archiveYear: Readonly<Ref<number>>
nominationPhase: Readonly<Ref<boolean>>
votingPhase: Readonly<Ref<boolean>>
reviewPhase: Readonly<Ref<boolean>>
preparationPhase: Readonly<Ref<boolean>>
completedPhase: Readonly<Ref<boolean>>
initializeHomeInteractions: () => Promise<void>
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeIndex: number; description: string }) => Promise<void>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
@@ -56,7 +56,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -66,10 +66,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
const rootEl = ref<HTMLElement | null>(null)
const landingLoaderVisible = ref(true)
const nominationCatEl = ref<HTMLSelectElement | null>(null)
const nominationNameEl = ref<HTMLInputElement | null>(null)
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
const clipUrlEl = ref<HTMLInputElement | null>(null)
const clipNomEl = ref<HTMLSelectElement | null>(null)
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
const countdownRefs = {
labelEl: ref<HTMLElement | null>(null),
@@ -93,7 +92,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleClipSubmit() {
return submitClip({
clipUrl: clipUrlEl.value?.value.trim() ?? '',
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
selectedNomineeQuery: clipNomSearchEl.value?.value.trim() ?? '',
description: clipDescEl.value?.value.trim() ?? '',
})
}
@@ -101,7 +100,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
name: nominationNameEl.value?.value.trim() ?? '',
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
@@ -120,10 +118,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]')
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
clipNomEl.value = root.querySelector('[data-dc-ref="clipNomRef"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
}
@@ -178,7 +175,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function resolveSelectedPhaseKey(): HomeTimelineKey {
if (nominationPhase.value) return 'nomination'
if (votingPhase.value) return 'voting'
if (reviewPhase.value) return 'review'
if (preparationPhase.value) return 'preparation'
if (completedPhase.value) return 'completed'
return 'show'
}
@@ -297,7 +294,7 @@ function resolvePhaseCountdownTarget(
}
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'preparation', 'show']
return phaseOrder
.map((key): TimelineScheduleItem | null => {
@@ -358,8 +355,8 @@ function phaseTitle(key: HomeTimelineKey) {
? 'Nominierung'
: key === 'voting'
? 'Voting'
: key === 'review'
? 'Review & Auswertung'
: key === 'preparation'
? 'Aufbereitung'
: 'Award Show'
}

Some files were not shown because too many files have changed in this diff Show More