Add team roles and content management updates
@@ -6,6 +6,8 @@ public static class ApplicationDefaults
|
|||||||
[
|
[
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:5174",
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
"http://localhost:4173",
|
"http://localhost:4173",
|
||||||
"http://127.0.0.1:4173",
|
"http://127.0.0.1:4173",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ public static class SeasonMappings
|
|||||||
return "show";
|
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"))
|
if (value.Contains("vot"))
|
||||||
@@ -123,7 +123,7 @@ public static class SeasonMappings
|
|||||||
|
|
||||||
public static string ResolveTimelineState(string itemKey, string currentPhaseKey)
|
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))
|
if (string.Equals(currentPhaseKey, "completed", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
|
return phaseOrder.Contains(itemKey) ? "done" : "upcoming";
|
||||||
@@ -181,8 +181,8 @@ public static class SeasonMappings
|
|||||||
|
|
||||||
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
|
public static FooterLinkDto[] BuildFooterLinks(SiteSettings settings) =>
|
||||||
[
|
[
|
||||||
new FooterLinkDto("Impressum", settings.ImprintUrl),
|
new FooterLinkDto("imprint", "Impressum", settings.ImprintUrl, settings.ImprintContent),
|
||||||
new FooterLinkDto("Kontakt", settings.ContactUrl),
|
new FooterLinkDto("contact", "Kontakt", settings.ContactUrl, settings.ContactContent),
|
||||||
new FooterLinkDto("Sponsoren & Partner", settings.SponsorsUrl),
|
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,
|
string? PrivacyPolicyUpdatedBy,
|
||||||
DateTimeOffset? PrivacyPolicyUpdatedAt,
|
DateTimeOffset? PrivacyPolicyUpdatedAt,
|
||||||
string ImprintUrl,
|
string ImprintUrl,
|
||||||
|
string ImprintContent,
|
||||||
string ContactUrl,
|
string ContactUrl,
|
||||||
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
|
string SponsorsContent,
|
||||||
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
IEnumerable<PublicSocialLinkDto> SocialLinks,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq);
|
||||||
|
|
||||||
@@ -21,8 +24,11 @@ public sealed record UpdateSiteSettingsRequest(
|
|||||||
string PrivacyEmail,
|
string PrivacyEmail,
|
||||||
string PrivacyPolicyContent,
|
string PrivacyPolicyContent,
|
||||||
string ImprintUrl,
|
string ImprintUrl,
|
||||||
|
string ImprintContent,
|
||||||
string ContactUrl,
|
string ContactUrl,
|
||||||
|
string ContactContent,
|
||||||
string SponsorsUrl,
|
string SponsorsUrl,
|
||||||
|
string SponsorsContent,
|
||||||
PublicSocialLinkDto[] SocialLinks,
|
PublicSocialLinkDto[] SocialLinks,
|
||||||
FaqItemDto[] Faq);
|
FaqItemDto[] Faq);
|
||||||
|
|
||||||
@@ -33,6 +39,12 @@ public sealed record AdminOperationalSettingsResponse(
|
|||||||
bool DemoLoginPasswordSet,
|
bool DemoLoginPasswordSet,
|
||||||
string DemoLoginTwitchUserId,
|
string DemoLoginTwitchUserId,
|
||||||
string DemoLoginDisplayName,
|
string DemoLoginDisplayName,
|
||||||
|
bool TwitchAuthManagedByDatabase,
|
||||||
|
bool TwitchAuthConfigured,
|
||||||
|
string TwitchClientId,
|
||||||
|
bool TwitchClientSecretSet,
|
||||||
|
string TwitchRedirectUri,
|
||||||
|
string TwitchScope,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
@@ -43,6 +55,10 @@ public sealed record UpdateOperationalSettingsRequest(
|
|||||||
string? DemoLoginPassword,
|
string? DemoLoginPassword,
|
||||||
string DemoLoginTwitchUserId,
|
string DemoLoginTwitchUserId,
|
||||||
string DemoLoginDisplayName,
|
string DemoLoginDisplayName,
|
||||||
|
string TwitchClientId,
|
||||||
|
string? TwitchClientSecret,
|
||||||
|
string TwitchRedirectUri,
|
||||||
|
string TwitchScope,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -10,8 +10,37 @@ public sealed record DemoLoginRequest(
|
|||||||
string? Email,
|
string? Email,
|
||||||
string? Password);
|
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(
|
public sealed record AuthSessionDto(
|
||||||
string SessionToken,
|
string SessionToken,
|
||||||
string TwitchUserId,
|
string TwitchUserId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Role);
|
string Role,
|
||||||
|
IEnumerable<string> PermissionKeys,
|
||||||
|
bool MustChangePassword = false,
|
||||||
|
string? TeamLogin = null,
|
||||||
|
string? BoundTwitchUserId = null,
|
||||||
|
string? BoundTwitchDisplayName = null);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public sealed record WinnerPreviewDto(
|
|||||||
string WinnerPlatform,
|
string WinnerPlatform,
|
||||||
string WinnerUrl);
|
string WinnerUrl);
|
||||||
|
|
||||||
|
public sealed record ArchiveYearDto(
|
||||||
|
int Year,
|
||||||
|
int WinnerCount);
|
||||||
|
|
||||||
public sealed record FaqItemDto(string Question, string Answer);
|
public sealed record FaqItemDto(string Question, string Answer);
|
||||||
|
|
||||||
public sealed record PublicSocialLinkDto(
|
public sealed record PublicSocialLinkDto(
|
||||||
@@ -33,8 +37,10 @@ public sealed record PublicSocialLinkDto(
|
|||||||
bool ShowOnCommunity = true);
|
bool ShowOnCommunity = true);
|
||||||
|
|
||||||
public sealed record FooterLinkDto(
|
public sealed record FooterLinkDto(
|
||||||
|
string Key,
|
||||||
string Label,
|
string Label,
|
||||||
string Url);
|
string Url,
|
||||||
|
string Content);
|
||||||
|
|
||||||
public sealed record PublicSiteContentDto(
|
public sealed record PublicSiteContentDto(
|
||||||
string HostDisplayName,
|
string HostDisplayName,
|
||||||
@@ -64,5 +70,6 @@ public sealed record OverviewResponse(
|
|||||||
IEnumerable<TimelineItem> Timeline,
|
IEnumerable<TimelineItem> Timeline,
|
||||||
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
|
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
|
||||||
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
IEnumerable<WinnerPreviewDto> WinnersPreview,
|
||||||
|
IEnumerable<ArchiveYearDto> ArchiveYears,
|
||||||
PublicSiteContentDto SiteContent,
|
PublicSiteContentDto SiteContent,
|
||||||
IEnumerable<FaqItemDto> Faq);
|
IEnumerable<FaqItemDto> Faq);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Backend.Contracts;
|
namespace Backend.Contracts;
|
||||||
|
|
||||||
public sealed record NominationEntryRequest(
|
public sealed record NominationEntryRequest(
|
||||||
string Name,
|
string? Name,
|
||||||
string StreamUrl);
|
string StreamUrl);
|
||||||
|
|
||||||
public sealed record CreateNominationRequest(
|
public sealed record CreateNominationRequest(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
|||||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||||
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
|
||||||
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
|
public DbSet<SiteSettings> SiteSettings => Set<SiteSettings>();
|
||||||
|
public DbSet<TeamMember> TeamMembers => Set<TeamMember>();
|
||||||
|
public DbSet<TeamRolePermission> TeamRolePermissions => Set<TeamRolePermission>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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.DemoLoginPasswordSalt).HasMaxLength(80);
|
||||||
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
|
entity.Property(item => item.DemoLoginTwitchUserId).HasMaxLength(120);
|
||||||
entity.Property(item => item.DemoLoginDisplayName).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.MaintenanceTitle).HasMaxLength(120);
|
||||||
entity.Property(item => item.MaintenanceMessage).HasMaxLength(600);
|
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 =>
|
modelBuilder.Entity<Category>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
|
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ public static class OperationalTablesBootstrapper
|
|||||||
ALTER TABLE "SiteSettings"
|
ALTER TABLE "SiteSettings"
|
||||||
ADD COLUMN IF NOT EXISTS "RiskRulesJson" text NOT NULL DEFAULT '[]';
|
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" (
|
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
"SeasonId" integer NULL,
|
"SeasonId" integer NULL,
|
||||||
@@ -128,5 +143,52 @@ public static class OperationalTablesBootstrapper
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
CREATE INDEX IF NOT EXISTS "IX_Nominations_SeasonId_Status"
|
||||||
ON "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");
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,35 @@ internal static class SeedCatalog
|
|||||||
new("Discord", "discord", "https://discord.gg/jayuhime", "discord"),
|
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 =
|
internal static readonly CandidateSeed[] CurrentCandidateSeeds =
|
||||||
[
|
[
|
||||||
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
new("vtuber-des-jahres", "Hoshimi Miyu", "@hoshimimiyu", "Twitch"),
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ Keine Weitergabe zu Werbezwecken. Technische Dienstleister verarbeiten Daten aus
|
|||||||
PrivacyPolicyUpdatedBy = "seed",
|
PrivacyPolicyUpdatedBy = "seed",
|
||||||
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
|
PrivacyPolicyUpdatedAt = new DateTimeOffset(2026, 6, 23, 0, 0, 0, TimeSpan.Zero),
|
||||||
ImprintUrl = "https://vtuber-star-awards.de/impressum",
|
ImprintUrl = "https://vtuber-star-awards.de/impressum",
|
||||||
|
ImprintContent = SeedCatalog.DefaultImprintContent,
|
||||||
ContactUrl = "https://vtuber-star-awards.de/kontakt",
|
ContactUrl = "https://vtuber-star-awards.de/kontakt",
|
||||||
|
ContactContent = SeedCatalog.DefaultContactContent,
|
||||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||||
|
SponsorsContent = SeedCatalog.DefaultSponsorsContent,
|
||||||
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults),
|
||||||
SocialLinksJson = JsonSerializer.Serialize(new[]
|
SocialLinksJson = JsonSerializer.Serialize(new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ public static partial class SeedDataBootstrapper
|
|||||||
{
|
{
|
||||||
settings.RiskRulesJson = RiskRuleSettings.Serialize(RiskRuleSettings.Defaults);
|
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)
|
private static bool HasValidSiteArray(string? json, params string[] requiredKeys)
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ public sealed class SiteSettings
|
|||||||
public string? PrivacyPolicyUpdatedBy { get; set; }
|
public string? PrivacyPolicyUpdatedBy { get; set; }
|
||||||
public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; }
|
public DateTimeOffset? PrivacyPolicyUpdatedAt { get; set; }
|
||||||
public string ImprintUrl { get; set; } = string.Empty;
|
public string ImprintUrl { get; set; } = string.Empty;
|
||||||
|
public string ImprintContent { get; set; } = string.Empty;
|
||||||
public string ContactUrl { 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 SponsorsUrl { get; set; } = string.Empty;
|
||||||
|
public string SponsorsContent { get; set; } = string.Empty;
|
||||||
public string SocialLinksJson { get; set; } = "[]";
|
public string SocialLinksJson { get; set; } = "[]";
|
||||||
public string FaqJson { get; set; } = "[]";
|
public string FaqJson { get; set; } = "[]";
|
||||||
public string RiskRulesJson { get; set; } = "[]";
|
public string RiskRulesJson { get; set; } = "[]";
|
||||||
@@ -23,6 +26,11 @@ public sealed class SiteSettings
|
|||||||
public string DemoLoginPasswordSalt { get; set; } = string.Empty;
|
public string DemoLoginPasswordSalt { get; set; } = string.Empty;
|
||||||
public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin";
|
public string DemoLoginTwitchUserId { get; set; } = "jayuhime_admin";
|
||||||
public string DemoLoginDisplayName { 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 bool MaintenanceModeEnabled { get; set; }
|
||||||
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
public string MaintenanceTitle { get; set; } = "Sternenpause";
|
||||||
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
public string MaintenanceMessage { get; set; } = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.";
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
using Backend.Data;
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
@@ -8,8 +9,14 @@ public static class AdminDashboardEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi();
|
group.MapGet("/dashboard", GetDashboard)
|
||||||
group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +31,7 @@ public static class AdminDashboardEndpoints
|
|||||||
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
|
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
|
||||||
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.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 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 riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
|
||||||
|
|
||||||
var topCategoryNames = await db.VoteEntries
|
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("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
|
||||||
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen 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("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"),
|
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
|
||||||
},
|
},
|
||||||
activityItems,
|
activityItems,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
using Backend.Security;
|
using Backend.Security;
|
||||||
|
using Backend.Data;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
@@ -7,4 +8,54 @@ internal static class AdminEndpointConventions
|
|||||||
{
|
{
|
||||||
public static UserSession CurrentSession(HttpContext context) =>
|
public static UserSession CurrentSession(HttpContext context) =>
|
||||||
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
|
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequirePermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string permissionKey)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||||
|
if (!await AdminPermissionCatalog.HasPermissionAsync(db, session.Role, permissionKey, context.HttpContext.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = $"Diese Admin-Aktion braucht die Berechtigung '{permissionKey}'." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequireWritePermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string permissionKey)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
if (AdminRoles.Normalize(session.Role) == AdminRoles.OrganizationTeam)
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Organisation Team hat fuer diesen Bereich nur Leserechte." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await RequirePermission(context, next, permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<object?> RequireAnyPermission(
|
||||||
|
EndpointFilterInvocationContext context,
|
||||||
|
EndpointFilterDelegate next,
|
||||||
|
string[] permissionKeys)
|
||||||
|
{
|
||||||
|
var session = CurrentSession(context.HttpContext);
|
||||||
|
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
|
||||||
|
if (!await AdminPermissionCatalog.HasAnyPermissionAsync(db, session.Role, permissionKeys, context.HttpContext.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Diese Admin-Aktion braucht eine passende Rollenberechtigung." },
|
||||||
|
statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,26 +11,11 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
group.MapAdminSiteSettingsEndpoints();
|
group.MapAdminSiteSettingsEndpoints();
|
||||||
|
|
||||||
var managerGroup = group.MapGroup(string.Empty)
|
group.MapAdminDashboardEndpoints();
|
||||||
.AddEndpointFilter(RequireAdminWorkspaceRole);
|
group.MapAdminSeasonManagementEndpoints();
|
||||||
|
group.MapAdminModerationEndpoints();
|
||||||
managerGroup.MapAdminDashboardEndpoints();
|
group.MapAdminTeamEndpoints();
|
||||||
managerGroup.MapAdminSeasonManagementEndpoints();
|
|
||||||
managerGroup.MapAdminModerationEndpoints();
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async ValueTask<object?> RequireAdminWorkspaceRole(
|
|
||||||
EndpointFilterInvocationContext context,
|
|
||||||
EndpointFilterDelegate next)
|
|
||||||
{
|
|
||||||
var session = AdminEndpointConventions.CurrentSession(context.HttpContext);
|
|
||||||
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
|
|
||||||
{
|
|
||||||
return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await next(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,42 @@ public static partial class AdminModerationEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi();
|
group.MapDelete("/clips/{clipId:int}", DeleteClip)
|
||||||
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||||
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi();
|
.WithName("DeleteAdminClip")
|
||||||
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi();
|
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus)
|
||||||
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
|
||||||
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi();
|
.WithName("UpdateAdminClipStatus")
|
||||||
group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").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;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ public static partial class AdminModerationEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
|
||||||
? nomination.CandidateText
|
|
||||||
: request.DisplayName.Trim();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
if (string.IsNullOrWhiteSpace(rawDisplayName))
|
||||||
{
|
{
|
||||||
@@ -37,11 +35,19 @@ public static partial class AdminModerationEndpoints
|
|||||||
|
|
||||||
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
|
||||||
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
|
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 =>
|
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
|
||||||
item.SeasonId == nomination.SeasonId
|
item.SeasonId == nomination.SeasonId
|
||||||
&& item.CategoryId == nomination.CategoryId
|
&& 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;
|
var candidate = existingCandidate;
|
||||||
if (candidate is null)
|
if (candidate is null)
|
||||||
@@ -60,6 +66,8 @@ public static partial class AdminModerationEndpoints
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
candidate.DisplayName = rawDisplayName;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(channelSlug))
|
if (!string.IsNullOrWhiteSpace(channelSlug))
|
||||||
{
|
{
|
||||||
candidate.ChannelSlug = channelSlug;
|
candidate.ChannelSlug = channelSlug;
|
||||||
|
|||||||
@@ -262,11 +262,6 @@ public static partial class AdminModerationEndpoints
|
|||||||
IAdminAuditService adminAuditService)
|
IAdminAuditService adminAuditService)
|
||||||
{
|
{
|
||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
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);
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
if (settings is null)
|
if (settings is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
var pendingNominations = await db.Nominations
|
var pendingNominations = await db.Nominations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending" && item.CandidateText != null)
|
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||||
.OrderByDescending(item => item.CreatedAt)
|
.OrderByDescending(item => item.CreatedAt)
|
||||||
.Select(item => new AdminNominationReviewItemDto(
|
.Select(item => new AdminNominationReviewItemDto(
|
||||||
item.Id,
|
item.Id,
|
||||||
item.CategoryId,
|
item.CategoryId,
|
||||||
item.Category.Name,
|
item.Category.Name,
|
||||||
item.SubmittedByTwitchId,
|
item.SubmittedByTwitchId,
|
||||||
item.CandidateText!,
|
item.CandidateText ?? string.Empty,
|
||||||
item.StreamUrl,
|
item.StreamUrl,
|
||||||
item.Status,
|
item.Status,
|
||||||
item.CreatedAt,
|
item.CreatedAt,
|
||||||
|
|||||||
@@ -4,19 +4,58 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi();
|
group.MapGet("/seasons", GetSeasons)
|
||||||
group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||||
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi();
|
.WithName("GetAdminSeasons")
|
||||||
group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi();
|
group.MapPost("/seasons", CreateSeason)
|
||||||
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
|
||||||
group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi();
|
.WithName("CreateAdminSeason")
|
||||||
group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi();
|
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail)
|
||||||
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
|
||||||
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi();
|
.WithName("GetAdminSeasonDetail")
|
||||||
group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi();
|
.WithOpenApi();
|
||||||
group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").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;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
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(
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||||
@@ -61,7 +61,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|
|
||||||
if (!IsKnownSeasonPhase(request.CurrentPhase))
|
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(
|
if (!SeasonMappings.IsSeasonScheduleValid(
|
||||||
@@ -92,6 +92,9 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
|| value.Contains("archiv")
|
|| value.Contains("archiv")
|
||||||
|| value.Contains("complete")
|
|| value.Contains("complete")
|
||||||
|| value.Contains("ended")
|
|| value.Contains("ended")
|
||||||
|
|| value.Contains("aufbereit")
|
||||||
|
|| value.Contains("vorbereit")
|
||||||
|
|| value.Contains("pause")
|
||||||
|| value.Contains("review")
|
|| value.Contains("review")
|
||||||
|| value.Contains("auswert")
|
|| value.Contains("auswert")
|
||||||
|| value.Contains("vot")
|
|| value.Contains("vot")
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public static partial class AdminSeasonManagementEndpoints
|
|||||||
season.ShowDate = request.ShowDate;
|
season.ShowDate = request.ShowDate;
|
||||||
season.ShowStartsAt = request.ShowStartsAt;
|
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;
|
season.IsCurrent = request.IsCurrent;
|
||||||
var actionType = "season.update";
|
var actionType = "season.update";
|
||||||
|
|||||||
@@ -16,10 +16,22 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
|
|
||||||
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi();
|
group.MapGet("/site-settings", GetSiteSettings)
|
||||||
group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi();
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Content))
|
||||||
group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi();
|
.WithName("GetAdminSiteSettings")
|
||||||
group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi();
|
.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;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +52,11 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.PrivacyPolicyUpdatedBy,
|
settings.PrivacyPolicyUpdatedBy,
|
||||||
settings.PrivacyPolicyUpdatedAt,
|
settings.PrivacyPolicyUpdatedAt,
|
||||||
settings.ImprintUrl,
|
settings.ImprintUrl,
|
||||||
|
settings.ImprintContent,
|
||||||
settings.ContactUrl,
|
settings.ContactUrl,
|
||||||
|
settings.ContactContent,
|
||||||
settings.SponsorsUrl,
|
settings.SponsorsUrl,
|
||||||
|
settings.SponsorsContent,
|
||||||
SeasonMappings.ReadSocialLinks(settings),
|
SeasonMappings.ReadSocialLinks(settings),
|
||||||
SeasonMappings.ReadFaqItems(settings)));
|
SeasonMappings.ReadFaqItems(settings)));
|
||||||
}
|
}
|
||||||
@@ -53,10 +68,6 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
IAdminAuditService adminAuditService)
|
IAdminAuditService adminAuditService)
|
||||||
{
|
{
|
||||||
var session = AdminEndpointConventions.CurrentSession(context);
|
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);
|
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
|
||||||
if (settings is null)
|
if (settings is null)
|
||||||
@@ -78,8 +89,11 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.ImprintUrl = request.ImprintUrl.Trim();
|
settings.ImprintUrl = request.ImprintUrl.Trim();
|
||||||
|
settings.ImprintContent = request.ImprintContent.Trim();
|
||||||
settings.ContactUrl = request.ContactUrl.Trim();
|
settings.ContactUrl = request.ContactUrl.Trim();
|
||||||
|
settings.ContactContent = request.ContactContent.Trim();
|
||||||
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
||||||
|
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||||
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
||||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||||
|
|
||||||
@@ -111,6 +125,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
|
||||||
|
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
|
||||||
return Results.Ok(new AdminOperationalSettingsResponse(
|
return Results.Ok(new AdminOperationalSettingsResponse(
|
||||||
usesDatabaseDemo,
|
usesDatabaseDemo,
|
||||||
usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration),
|
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 ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")),
|
||||||
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
|
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
|
||||||
usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"),
|
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,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
|
||||||
@@ -144,12 +165,18 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var before = CreateOperationalSettingsSnapshot(settings);
|
var before = CreateOperationalSettingsSnapshot(settings, configuration);
|
||||||
var loginIdentifier = request.DemoLoginEmail.Trim();
|
var loginIdentifier = request.DemoLoginEmail.Trim();
|
||||||
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
|
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
|
||||||
var displayName = request.DemoLoginDisplayName.Trim();
|
var displayName = request.DemoLoginDisplayName.Trim();
|
||||||
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
|
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
|
||||||
var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
|
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)
|
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;
|
settings.DemoLoginManagedByDatabase = true;
|
||||||
@@ -178,6 +218,22 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
settings.DemoLoginEmail = loginIdentifier;
|
settings.DemoLoginEmail = loginIdentifier;
|
||||||
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
|
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
|
||||||
settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName;
|
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)
|
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
|
||||||
? newPassword
|
? newPassword
|
||||||
@@ -201,8 +257,9 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
|
|
||||||
var changes = BuildOperationalSettingChanges(
|
var changes = BuildOperationalSettingChanges(
|
||||||
before,
|
before,
|
||||||
CreateOperationalSettingsSnapshot(settings),
|
CreateOperationalSettingsSnapshot(settings, configuration),
|
||||||
!string.IsNullOrWhiteSpace(passwordToPersist));
|
!string.IsNullOrWhiteSpace(passwordToPersist),
|
||||||
|
!string.IsNullOrWhiteSpace(twitchClientSecret));
|
||||||
|
|
||||||
adminAuditService.AddEntry(
|
adminAuditService.AddEntry(
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
@@ -225,6 +282,7 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
{
|
{
|
||||||
saved = true,
|
saved = true,
|
||||||
demoLoginPasswordSet = HasDatabaseDemoCredentials(settings),
|
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) =>
|
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||||
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
|
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)
|
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
|
||||||
@@ -263,7 +324,35 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
|
||||||
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
|
&& !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(
|
new(
|
||||||
settings.DemoLoginManagedByDatabase,
|
settings.DemoLoginManagedByDatabase,
|
||||||
settings.DemoLoginEnabled,
|
settings.DemoLoginEnabled,
|
||||||
@@ -271,6 +360,11 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
HasDatabaseDemoCredentials(settings),
|
HasDatabaseDemoCredentials(settings),
|
||||||
settings.DemoLoginTwitchUserId,
|
settings.DemoLoginTwitchUserId,
|
||||||
settings.DemoLoginDisplayName,
|
settings.DemoLoginDisplayName,
|
||||||
|
settings.TwitchAuthManagedByDatabase,
|
||||||
|
settings.TwitchClientId,
|
||||||
|
HasEffectiveTwitchClientSecret(settings, configuration),
|
||||||
|
settings.TwitchRedirectUri,
|
||||||
|
settings.TwitchScope,
|
||||||
settings.MaintenanceModeEnabled,
|
settings.MaintenanceModeEnabled,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
|
||||||
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
|
||||||
@@ -278,7 +372,8 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
private static object[] BuildOperationalSettingChanges(
|
private static object[] BuildOperationalSettingChanges(
|
||||||
OperationalSettingsSnapshot before,
|
OperationalSettingsSnapshot before,
|
||||||
OperationalSettingsSnapshot after,
|
OperationalSettingsSnapshot after,
|
||||||
bool passwordChanged)
|
bool passwordChanged,
|
||||||
|
bool twitchClientSecretChanged)
|
||||||
{
|
{
|
||||||
var changes = new List<object>();
|
var changes = new List<object>();
|
||||||
|
|
||||||
@@ -287,6 +382,22 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
|
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
|
||||||
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
|
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
|
||||||
AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName);
|
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)
|
if (passwordChanged)
|
||||||
{
|
{
|
||||||
@@ -347,7 +458,19 @@ public static class AdminSiteSettingsEndpoints
|
|||||||
bool DemoLoginPasswordSet,
|
bool DemoLoginPasswordSet,
|
||||||
string DemoLoginTwitchUserId,
|
string DemoLoginTwitchUserId,
|
||||||
string DemoLoginDisplayName,
|
string DemoLoginDisplayName,
|
||||||
|
bool TwitchAuthManagedByDatabase,
|
||||||
|
string TwitchClientId,
|
||||||
|
bool TwitchClientSecretSet,
|
||||||
|
string TwitchRedirectUri,
|
||||||
|
string TwitchScope,
|
||||||
bool MaintenanceModeEnabled,
|
bool MaintenanceModeEnabled,
|
||||||
string MaintenanceTitle,
|
string MaintenanceTitle,
|
||||||
string MaintenanceMessage);
|
string MaintenanceMessage);
|
||||||
|
|
||||||
|
private sealed record TwitchSettingsSnapshot(
|
||||||
|
string ClientId,
|
||||||
|
bool ClientSecretSet,
|
||||||
|
string RedirectUri,
|
||||||
|
string Scope,
|
||||||
|
bool Configured);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,562 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminTeamEndpoints
|
||||||
|
{
|
||||||
|
private const int MinPasswordLength = 10;
|
||||||
|
private const string PersonalOwnerLogin = "jayuhime";
|
||||||
|
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
|
||||||
|
|
||||||
|
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
|
||||||
|
[
|
||||||
|
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
|
||||||
|
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
|
||||||
|
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
|
||||||
|
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
|
||||||
|
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
|
||||||
|
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
|
||||||
|
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
|
||||||
|
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
|
||||||
|
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
|
||||||
|
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
|
||||||
|
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
|
||||||
|
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
|
||||||
|
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly AdminTeamRoleDto[] DefaultRoles =
|
||||||
|
[
|
||||||
|
new(AdminRoles.Owner, "Owner", "Jayuhime: vollständige Kontrolle inklusive Passwortreset und Betriebseinstellungen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Owner)),
|
||||||
|
new(AdminRoles.Creator, "Creator", "Persönliche Jayuhime-Rolle mit denselben Rechten wie Owner.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Creator)),
|
||||||
|
new(AdminRoles.Admin, "Admins", "Operatives Kernteam mit Schreibrechten in allen Award-Bereichen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Admin)),
|
||||||
|
new(AdminRoles.Member, "Mitglied", "Internes Team für Pflege, Nominierungen und Clip-Arbeit.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Member)),
|
||||||
|
new(AdminRoles.Reviewer, "Reviewer", "Review-Fokus für Nominierungen, Clips und Risiko-Hinweise.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Reviewer)),
|
||||||
|
new(AdminRoles.OrganizationTeam, "Organisation Team", "Externe Personen mit Read-only-Sicht auf ausgewählte Bereiche.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.OrganizationTeam)),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapAdminTeamEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/team", GetTeam)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("GetAdminTeam")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/team/members", CreateMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("CreateAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/team/members/{memberId:int}", UpdateMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("UpdateAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPost("/team/members/{memberId:int}/reset-password", ResetPassword)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("ResetAdminTeamMemberPassword")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapDelete("/team/members/{memberId:int}", DeleteMember)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("DeleteAdminTeamMember")
|
||||||
|
.WithOpenApi();
|
||||||
|
group.MapPut("/team/roles", UpdateRoles)
|
||||||
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
|
||||||
|
.WithName("UpdateAdminTeamRoles")
|
||||||
|
.WithOpenApi();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetTeam(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var members = await db.TeamMembers
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(item => item.Role == AdminRoles.Owner)
|
||||||
|
.ThenBy(item => item.Role)
|
||||||
|
.ThenBy(item => item.DisplayName)
|
||||||
|
.Select(item => ToMemberDto(item))
|
||||||
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminTeamResponse(
|
||||||
|
members,
|
||||||
|
await BuildRoleDtosAsync(db),
|
||||||
|
PermissionCatalog));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateMember(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
CreateTeamMemberRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
|
var normalizedLogin = NormalizeLogin(request.Login);
|
||||||
|
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||||
|
var displayName = NormalizeDisplayName(request.DisplayName);
|
||||||
|
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(normalizedRole) && !AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, null, normalizedLogin, normalizedRole, context.RequestAborted);
|
||||||
|
if (privilegedValidationError is not null)
|
||||||
|
{
|
||||||
|
return privilegedValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.TeamMembers.AnyAsync(item => item.Login == normalizedLogin, context.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedPassword = GeneratePassword();
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
|
||||||
|
var member = new TeamMember
|
||||||
|
{
|
||||||
|
Login = normalizedLogin,
|
||||||
|
DisplayName = displayName,
|
||||||
|
Role = normalizedRole,
|
||||||
|
PasswordHash = credentials.Hash,
|
||||||
|
PasswordSalt = credentials.Salt,
|
||||||
|
MustChangePassword = true,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
CreatedByTwitchId = session.TwitchUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.TeamMembers.Add(member);
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.create",
|
||||||
|
"team-member",
|
||||||
|
normalizedLogin,
|
||||||
|
$"Team-Login {displayName} wurde angelegt.",
|
||||||
|
new { member.Login, member.DisplayName, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateMember(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
UpdateTeamMemberRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedLogin = NormalizeLogin(request.Login);
|
||||||
|
var normalizedRole = AdminRoles.Normalize(request.Role);
|
||||||
|
var displayName = NormalizeDisplayName(request.DisplayName);
|
||||||
|
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
|
||||||
|
if (validationError is not null)
|
||||||
|
{
|
||||||
|
return validationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginChanged = !string.Equals(member.Login, normalizedLogin, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (loginChanged && !AdminRoles.CanDeleteTeamMembers(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Logins können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginChanged && await db.TeamMembers.AnyAsync(item => item.Id != member.Id && item.Login == normalizedLogin, context.RequestAborted))
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((AdminRoles.IsPrivilegedFullControlRole(member.Role) || AdminRoles.IsPrivilegedFullControlRole(normalizedRole)) && !AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator bearbeitet werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousLogin = member.Login;
|
||||||
|
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, member.Id, normalizedLogin, normalizedRole, context.RequestAborted);
|
||||||
|
if (privilegedValidationError is not null)
|
||||||
|
{
|
||||||
|
return privilegedValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
member.Login = normalizedLogin;
|
||||||
|
member.DisplayName = displayName;
|
||||||
|
member.Role = normalizedRole;
|
||||||
|
member.IsActive = request.IsActive;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.update",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Team-Login {member.DisplayName} wurde aktualisiert.",
|
||||||
|
new { previousLogin, member.Login, member.DisplayName, member.Role, member.IsActive },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
if (!member.IsActive)
|
||||||
|
{
|
||||||
|
DeactivateMemberSessions(db, member, previousLogin);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await SyncMemberSessionRolesAsync(db, member, previousLogin, context.RequestAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, member = ToMemberDto(member) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ResetPassword(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
if (!AdminRoles.CanResetTeamPasswords(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Passwort-Reset ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedPassword = GeneratePassword();
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
|
||||||
|
member.PasswordHash = credentials.Hash;
|
||||||
|
member.PasswordSalt = credentials.Salt;
|
||||||
|
member.MustChangePassword = true;
|
||||||
|
member.PasswordResetAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.password-reset",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Passwort für {member.DisplayName} wurde zurückgesetzt.",
|
||||||
|
new { member.Login, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteMember(
|
||||||
|
int memberId,
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
if (!AdminRoles.CanDeleteTeamMembers(session.Role))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Mitglieder löschen ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(member.Role) && !CanDeletePrivilegedMember(session, member))
|
||||||
|
{
|
||||||
|
return Results.Json(new { message = "Creator darf Owner-Konten löschen. Creator-Konten können nur vom eigenen Creator-Account gelöscht werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeactivateMemberSessions(db, member);
|
||||||
|
db.TeamMembers.Remove(member);
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.member.delete",
|
||||||
|
"team-member",
|
||||||
|
member.Id.ToString(),
|
||||||
|
$"Team-Login {member.DisplayName} wurde gelöscht.",
|
||||||
|
new { member.Login, member.DisplayName, member.Role },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new DeleteTeamMemberResponse(true, memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateRoles(
|
||||||
|
HttpContext context,
|
||||||
|
AwardsDbContext db,
|
||||||
|
UpdateTeamRolesRequest request,
|
||||||
|
IAdminAuditService adminAuditService)
|
||||||
|
{
|
||||||
|
var session = AdminEndpointConventions.CurrentSession(context);
|
||||||
|
|
||||||
|
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
|
||||||
|
|
||||||
|
foreach (var roleUpdate in request.Roles ?? [])
|
||||||
|
{
|
||||||
|
var role = AdminRoles.Normalize(roleUpdate.Key);
|
||||||
|
if (!allowedRoles.Contains(role))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Unbekannte Rolle: {roleUpdate.Key}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissionKeys = (roleUpdate.PermissionKeys ?? [])
|
||||||
|
.Select(item => item.Trim())
|
||||||
|
.Where(item => allowedPermissions.Contains(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(item => item)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingRows.TryGetValue(role, out var row))
|
||||||
|
{
|
||||||
|
row = new TeamRolePermission { Role = role };
|
||||||
|
db.TeamRolePermissions.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
|
||||||
|
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
row.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminAuditService.AddEntry(
|
||||||
|
session.TwitchUserId,
|
||||||
|
"team.roles.update",
|
||||||
|
"team-role",
|
||||||
|
"matrix",
|
||||||
|
"Team-Rollenberechtigungen wurden gespeichert.",
|
||||||
|
new { roleCount = request.Roles?.Length ?? 0 },
|
||||||
|
RequestMetadataReader.Read(context));
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(new { saved = true, roles = await BuildRoleDtosAsync(db) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<AdminTeamRoleDto[]> BuildRoleDtosAsync(AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var overrides = await db.TeamRolePermissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(item => item.Role, item => item.PermissionsJson);
|
||||||
|
|
||||||
|
return DefaultRoles
|
||||||
|
.Select(role =>
|
||||||
|
{
|
||||||
|
var permissions = overrides.TryGetValue(role.Key, out var json)
|
||||||
|
? ReadPermissionKeys(json, role.PermissionKeys)
|
||||||
|
: role.PermissionKeys;
|
||||||
|
|
||||||
|
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
|
||||||
|
{
|
||||||
|
permissions = AdminPermissionCatalog.AllPermissionKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return role with { PermissionKeys = permissions };
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
|
||||||
|
new(
|
||||||
|
member.Id,
|
||||||
|
member.Login,
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
member.BoundTwitchUserId,
|
||||||
|
member.BoundTwitchDisplayName,
|
||||||
|
member.IsActive,
|
||||||
|
member.MustChangePassword,
|
||||||
|
member.CreatedAt,
|
||||||
|
member.UpdatedAt,
|
||||||
|
member.LastLoginAt,
|
||||||
|
member.TwitchBoundAt,
|
||||||
|
member.PasswordResetAt);
|
||||||
|
|
||||||
|
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<string[]>(json) ?? fallback;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult? ValidateMemberFields(string login, string displayName, string role)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(login) || login.Length > 80)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login ist erforderlich und darf maximal 80 Zeichen haben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!login.All(value => char.IsLetterOrDigit(value) || value is '_' or '-' or '.'))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Login darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Anzeigename ist erforderlich und darf maximal 120 Zeichen haben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminRoles.IsKnownRole(role) || role == AdminRoles.Viewer || role == AdminRoles.ContentAdmin)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte wähle Owner, Creator, Admins, Mitglied, Reviewer oder Organisation Team." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult?> ValidatePrivilegedMemberChangeAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
int? memberId,
|
||||||
|
string login,
|
||||||
|
string role,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (role == AdminRoles.Owner)
|
||||||
|
{
|
||||||
|
if (!string.Equals(login, PersonalOwnerLogin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Die Owner-Rolle ist nur für @{PersonalOwnerLogin} vorgesehen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerExists = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Role == AdminRoles.Owner && (!memberId.HasValue || item.Id != memberId.Value),
|
||||||
|
cancellationToken);
|
||||||
|
return ownerExists
|
||||||
|
? Results.Conflict(new { message = "Es darf nur einen Owner-Account geben." })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role != AdminRoles.Creator)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PersonalCreatorLogins.Contains(login, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Die Creator-Rolle ist nur für @{string.Join(" oder @", PersonalCreatorLogins)} vorgesehen." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var creatorExists = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Role == AdminRoles.Creator && (!memberId.HasValue || item.Id != memberId.Value),
|
||||||
|
cancellationToken);
|
||||||
|
return creatorExists
|
||||||
|
? Results.Conflict(new { message = "Es darf nur einen Creator-Account geben." })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeactivateMemberSessions(AwardsDbContext db, TeamMember member, string? previousLogin = null)
|
||||||
|
{
|
||||||
|
var teamSessionId = $"team:{member.Login}";
|
||||||
|
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
|
||||||
|
var twitchUserId = member.BoundTwitchUserId;
|
||||||
|
foreach (var session in db.UserSessions.Where(item =>
|
||||||
|
item.TwitchUserId == teamSessionId
|
||||||
|
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|
||||||
|
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
|
||||||
|
{
|
||||||
|
session.IsActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOwnTeamAccount(UserSession session, TeamMember member)
|
||||||
|
{
|
||||||
|
var teamLogin = AuthEndpoints.ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(teamLogin)
|
||||||
|
&& string.Equals(teamLogin, member.Login, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.IsNullOrWhiteSpace(member.BoundTwitchUserId)
|
||||||
|
&& string.Equals(session.TwitchUserId, member.BoundTwitchUserId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanDeletePrivilegedMember(UserSession session, TeamMember member)
|
||||||
|
{
|
||||||
|
if (IsOwnTeamAccount(session, member))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdminRoles.Normalize(session.Role) == AdminRoles.Creator
|
||||||
|
&& AdminRoles.Normalize(member.Role) == AdminRoles.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SyncMemberSessionRolesAsync(AwardsDbContext db, TeamMember member, string? previousLogin, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var teamSessionId = $"team:{member.Login}";
|
||||||
|
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
|
||||||
|
var twitchUserId = member.BoundTwitchUserId;
|
||||||
|
var sessions = await db.UserSessions
|
||||||
|
.Where(item => item.IsActive
|
||||||
|
&& (item.TwitchUserId == teamSessionId
|
||||||
|
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|
||||||
|
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
if (session.TwitchUserId == previousTeamSessionId)
|
||||||
|
{
|
||||||
|
session.TwitchUserId = teamSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.DisplayName = member.DisplayName;
|
||||||
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeLogin(string value) =>
|
||||||
|
value.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeDisplayName(string value) =>
|
||||||
|
value.Trim();
|
||||||
|
|
||||||
|
private static string GeneratePassword()
|
||||||
|
{
|
||||||
|
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!?#%";
|
||||||
|
Span<char> chars = stackalloc char[MinPasswordLength];
|
||||||
|
Span<byte> bytes = stackalloc byte[MinPasswordLength];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
|
||||||
|
for (var index = 0; index < chars.Length; index++)
|
||||||
|
{
|
||||||
|
chars[index] = alphabet[bytes[index] % alphabet.Length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
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)
|
private static bool IsDemoLoginEnabled(IConfiguration configuration)
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
|
|||||||
await db.SaveChangesAsync(context.RequestAborted);
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(ToAuthSessionDto(session));
|
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,36 @@ public static partial class AuthEndpoints
|
|||||||
.WithName("DemoLogin")
|
.WithName("DemoLogin")
|
||||||
.WithOpenApi();
|
.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)
|
group.MapGet("/session", GetSession)
|
||||||
.WithName("GetSession")
|
.WithName("GetSession")
|
||||||
.WithOpenApi();
|
.WithOpenApi();
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
using Backend.Contracts;
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
using Backend.Domain;
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
using Backend.Services;
|
using Backend.Services;
|
||||||
|
|
||||||
namespace Backend.Endpoints;
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
public static partial class AuthEndpoints
|
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);
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@@ -14,7 +19,7 @@ public static partial class AuthEndpoints
|
|||||||
return Results.Unauthorized();
|
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)
|
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 });
|
return Results.Ok(new { loggedOut = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthSessionDto ToAuthSessionDto(UserSession session) =>
|
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
|
||||||
new(
|
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.SessionToken,
|
||||||
session.TwitchUserId,
|
session.TwitchUserId,
|
||||||
session.DisplayName,
|
teamMember?.DisplayName ?? session.DisplayName,
|
||||||
session.Role);
|
AdminRoles.Normalize(sessionRole),
|
||||||
|
permissionKeys,
|
||||||
|
teamMember?.MustChangePassword ?? mustChangePassword,
|
||||||
|
teamMember?.Login,
|
||||||
|
teamMember?.BoundTwitchUserId,
|
||||||
|
teamMember?.BoundTwitchDisplayName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using Backend.Common;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Domain;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private const string TeamSessionPrefix = "team:";
|
||||||
|
private const int MinTeamPasswordLength = 10;
|
||||||
|
|
||||||
|
private static async Task<IResult> TeamLogin(
|
||||||
|
HttpContext context,
|
||||||
|
TeamLoginRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var login = NormalizeTeamLogin(request.Login);
|
||||||
|
var password = request.Password ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
|
||||||
|
if (member is null
|
||||||
|
|| !member.IsActive
|
||||||
|
|| !DemoCredentialHasher.VerifyPassword(password, member.PasswordHash, member.PasswordSalt))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
BuildTeamSessionId(member.Login),
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
RequestMetadataReader.Read(context),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> TeamTwitchLogin(
|
||||||
|
HttpContext context,
|
||||||
|
TeamTwitchLoginRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||||
|
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||||
|
item => item.BoundTwitchUserId == twitchUserId,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
twitchUserId,
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
RequestMetadataReader.Read(context),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ChangePassword(
|
||||||
|
HttpContext context,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPassword = request.CurrentPassword ?? string.Empty;
|
||||||
|
var newPassword = request.NewPassword?.Trim() ?? string.Empty;
|
||||||
|
if (!DemoCredentialHasher.VerifyPassword(currentPassword, member.PasswordHash, member.PasswordSalt))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das aktuelle Passwort stimmt nicht." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.Length < MinTeamPasswordLength)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = $"Das neue Passwort muss mindestens {MinTeamPasswordLength} Zeichen lang sein." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DemoCredentialHasher.FixedTimePlainTextEquals(currentPassword, newPassword))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Das neue Passwort muss sich vom aktuellen Passwort unterscheiden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = DemoCredentialHasher.HashPassword(newPassword);
|
||||||
|
member.PasswordHash = credentials.Hash;
|
||||||
|
member.PasswordSalt = credentials.Salt;
|
||||||
|
member.MustChangePassword = false;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
session.DisplayName = member.DisplayName;
|
||||||
|
session.Role = AdminRoles.Normalize(member.Role);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BindTeamTwitch(
|
||||||
|
HttpContext context,
|
||||||
|
BindTeamTwitchRequest request,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.MustChangePassword)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||||
|
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
|
||||||
|
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (alreadyBound)
|
||||||
|
{
|
||||||
|
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||||
|
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||||
|
{
|
||||||
|
oldSession.IsActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member.BoundTwitchUserId = twitchUserId;
|
||||||
|
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
|
||||||
|
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTeamSessionId(string login) =>
|
||||||
|
$"{TeamSessionPrefix}{login}";
|
||||||
|
|
||||||
|
internal static string ReadTeamLoginFromSession(string twitchUserId) =>
|
||||||
|
twitchUserId.StartsWith(TeamSessionPrefix, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? NormalizeTeamLogin(twitchUserId[TeamSessionPrefix.Length..])
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
internal static async Task<TeamMember?> FindTeamMemberForSessionAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
UserSession session,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var login = ReadTeamLoginFromSession(session.TwitchUserId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(login))
|
||||||
|
{
|
||||||
|
return await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUserId = NormalizeTwitchUserId(session.TwitchUserId);
|
||||||
|
return string.IsNullOrWhiteSpace(twitchUserId)
|
||||||
|
? null
|
||||||
|
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeTeamLogin(string value) =>
|
||||||
|
value.Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeTwitchUserId(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeTwitchDisplayName(string? value) =>
|
||||||
|
(value ?? string.Empty).Trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Backend.Common;
|
||||||
|
using Backend.Configuration;
|
||||||
|
using Backend.Contracts;
|
||||||
|
using Backend.Data;
|
||||||
|
using Backend.Security;
|
||||||
|
using Backend.Services;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Backend.Endpoints;
|
||||||
|
|
||||||
|
public static partial class AuthEndpoints
|
||||||
|
{
|
||||||
|
private const string TwitchLoginPurpose = "team-login";
|
||||||
|
private const string TwitchBindingPurpose = "team-binding";
|
||||||
|
private const string TwitchStateCachePrefix = "twitch-oauth-state:";
|
||||||
|
private static readonly TimeSpan TwitchStateLifetime = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private static async Task<IResult> StartTwitchAuthorization(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchAuthorizeRequest request,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IOptions<TwitchAuthOptions> twitchOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||||
|
if (!TwitchAuthConfigured(options))
|
||||||
|
{
|
||||||
|
return Results.Json(
|
||||||
|
new { message = "Twitch OAuth ist noch nicht konfiguriert. Bitte TwitchAuth:ClientId und TwitchAuth:ClientSecret setzen." },
|
||||||
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var purpose = NormalizeTwitchPurpose(request.Purpose);
|
||||||
|
if (purpose is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Unbekannter Twitch-Login-Zweck." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var frontendOrigin = NormalizeFrontendOrigin(request.FrontendOrigin, configuration, environment);
|
||||||
|
if (frontendOrigin is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { message = "Frontend-Origin ist fuer Twitch OAuth nicht erlaubt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionToken = string.Empty;
|
||||||
|
if (purpose == TwitchBindingPurpose)
|
||||||
|
{
|
||||||
|
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken = session.SessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||||
|
memoryCache.Set(
|
||||||
|
$"{TwitchStateCachePrefix}{state}",
|
||||||
|
new TwitchOAuthState(purpose, NormalizeReturnUrl(request.ReturnUrl), frontendOrigin, sessionToken),
|
||||||
|
TwitchStateLifetime);
|
||||||
|
|
||||||
|
var authorizationParams = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["client_id"] = options.ClientId,
|
||||||
|
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||||
|
["response_type"] = "code",
|
||||||
|
["state"] = state,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Scope))
|
||||||
|
{
|
||||||
|
authorizationParams["scope"] = options.Scope.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorizationUrl = QueryHelpers.AddQueryString(
|
||||||
|
"https://id.twitch.tv/oauth2/authorize",
|
||||||
|
authorizationParams);
|
||||||
|
|
||||||
|
return Results.Ok(new TwitchAuthorizeResponse(authorizationUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchAuthorization(
|
||||||
|
HttpContext context,
|
||||||
|
string? code,
|
||||||
|
string? state,
|
||||||
|
string? error,
|
||||||
|
string? error_description,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IOptions<TwitchAuthOptions> twitchOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state)
|
||||||
|
|| !memoryCache.TryGetValue<TwitchOAuthState>($"{TwitchStateCachePrefix}{state}", out var oauthState)
|
||||||
|
|| oauthState is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError("/", "Der Twitch-Login ist abgelaufen. Bitte starte ihn erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.Remove($"{TwitchStateCachePrefix}{state}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(error))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(
|
||||||
|
oauthState,
|
||||||
|
string.IsNullOrWhiteSpace(error_description)
|
||||||
|
? "Twitch hat den Login abgebrochen."
|
||||||
|
: error_description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen Login-Code zurueckgegeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
|
||||||
|
if (!TwitchAuthConfigured(options))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch OAuth ist auf dem Server nicht vollstaendig konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpClient = httpClientFactory.CreateClient();
|
||||||
|
var token = await ExchangeTwitchCodeAsync(httpClient, context, options, code, context.RequestAborted);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte den Login-Code nicht bestaetigen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchUser = await LoadTwitchUserAsync(httpClient, options.ClientId, token.AccessToken, context.RequestAborted);
|
||||||
|
if (twitchUser is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte dein Profil nicht laden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var twitchLogin = NormalizeTwitchUserId(twitchUser.Login);
|
||||||
|
if (string.IsNullOrWhiteSpace(twitchLogin))
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen gueltigen Login-Namen geliefert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthState.Purpose == TwitchBindingPurpose)
|
||||||
|
{
|
||||||
|
return await CompleteTwitchBindingAsync(context, oauthState, twitchLogin, twitchUser.DisplayName, db);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await CompleteTwitchTeamLoginAsync(context, oauthState, twitchLogin, db, userSessionService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchBindingAsync(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchOAuthState oauthState,
|
||||||
|
string twitchLogin,
|
||||||
|
string twitchDisplayName,
|
||||||
|
AwardsDbContext db)
|
||||||
|
{
|
||||||
|
var session = await db.UserSessions.FirstOrDefaultAsync(
|
||||||
|
item => item.SessionToken == oauthState.SessionToken && item.IsActive,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Deine Team-Session ist abgelaufen. Bitte melde dich erneut an.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Dieser Account ist kein aktiver Team-Login.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.MustChangePassword)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Bitte aendere zuerst dein temporaeres Passwort.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||||
|
item => item.Id != member.Id && item.BoundTwitchUserId == twitchLogin,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (alreadyBound)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Dieser Twitch-Account ist bereits mit einem Team-Account verbunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||||
|
&& !string.Equals(previousTwitchUserId, twitchLogin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||||
|
{
|
||||||
|
oldSession.IsActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member.BoundTwitchUserId = twitchLogin;
|
||||||
|
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchLogin : twitchDisplayName;
|
||||||
|
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
return RedirectToTwitchCallback(oauthState, "connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
|
||||||
|
HttpContext context,
|
||||||
|
TwitchOAuthState oauthState,
|
||||||
|
string twitchLogin,
|
||||||
|
AwardsDbContext db,
|
||||||
|
IUserSessionService userSessionService)
|
||||||
|
{
|
||||||
|
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||||
|
item => item.BoundTwitchUserId == twitchLogin,
|
||||||
|
context.RequestAborted);
|
||||||
|
if (member is null || !member.IsActive)
|
||||||
|
{
|
||||||
|
return RedirectToTwitchCallbackError(oauthState, "Fuer diesen Twitch-Account ist kein aktiver Team-Account gebunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
var session = await userSessionService.CreateSessionAsync(
|
||||||
|
twitchLogin,
|
||||||
|
member.DisplayName,
|
||||||
|
member.Role,
|
||||||
|
RequestMetadataReader.Read(context),
|
||||||
|
context.RequestAborted);
|
||||||
|
|
||||||
|
return RedirectToTwitchCallback(oauthState, "authenticated", session.SessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TwitchTokenResponse?> ExchangeTwitchCodeAsync(
|
||||||
|
HttpClient httpClient,
|
||||||
|
HttpContext context,
|
||||||
|
TwitchAuthOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.PostAsync(
|
||||||
|
"https://id.twitch.tv/oauth2/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["client_id"] = options.ClientId,
|
||||||
|
["client_secret"] = options.ClientSecret,
|
||||||
|
["code"] = code,
|
||||||
|
["grant_type"] = "authorization_code",
|
||||||
|
["redirect_uri"] = ResolveRedirectUri(context, options),
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
return await JsonSerializer.DeserializeAsync<TwitchTokenResponse>(stream, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<TwitchUserResponseItem?> LoadTwitchUserAsync(
|
||||||
|
HttpClient httpClient,
|
||||||
|
string clientId,
|
||||||
|
string accessToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.twitch.tv/helix/users");
|
||||||
|
request.Headers.Add("Client-Id", clientId);
|
||||||
|
request.Headers.Authorization = new("Bearer", accessToken);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
var payload = await JsonSerializer.DeserializeAsync<TwitchUsersResponse>(stream, cancellationToken: cancellationToken);
|
||||||
|
return payload?.Data.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallback(TwitchOAuthState state, string status, string? sessionToken = null, string? message = null)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["status"] = status,
|
||||||
|
["returnUrl"] = state.ReturnUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionToken))
|
||||||
|
{
|
||||||
|
values["sessionToken"] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
values["message"] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = string.Join('&', values.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"));
|
||||||
|
return Results.Redirect($"{state.FrontendOrigin}/auth/twitch/callback#{fragment}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallbackError(TwitchOAuthState state, string message) =>
|
||||||
|
RedirectToTwitchCallback(state, "error", message: message);
|
||||||
|
|
||||||
|
private static IResult RedirectToTwitchCallbackError(string returnUrl, string message) =>
|
||||||
|
RedirectToTwitchCallback(new TwitchOAuthState(TwitchLoginPurpose, NormalizeReturnUrl(returnUrl), ApplicationDefaults.FrontendOrigins[0], string.Empty), "error", message: message);
|
||||||
|
|
||||||
|
private static bool TwitchAuthConfigured(TwitchAuthOptions options) =>
|
||||||
|
!string.IsNullOrWhiteSpace(options.ClientId)
|
||||||
|
&& !string.IsNullOrWhiteSpace(options.ClientSecret);
|
||||||
|
|
||||||
|
private static async Task<TwitchAuthOptions> ResolveEffectiveTwitchOptionsAsync(
|
||||||
|
AwardsDbContext db,
|
||||||
|
TwitchAuthOptions configuredOptions,
|
||||||
|
IConfiguration configuration,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
|
||||||
|
return new TwitchAuthOptions
|
||||||
|
{
|
||||||
|
ClientId = FirstConfigured(settings?.TwitchClientId, ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"), configuredOptions.ClientId),
|
||||||
|
ClientSecret = FirstConfigured(settings?.TwitchClientSecret, ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"), configuredOptions.ClientSecret),
|
||||||
|
RedirectUri = FirstConfigured(settings?.TwitchRedirectUri, ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"), configuredOptions.RedirectUri),
|
||||||
|
Scope = FirstConfigured(settings?.TwitchScope, ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"), configuredOptions.Scope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FirstConfigured(params string?[] values) =>
|
||||||
|
values.Select(value => value?.Trim() ?? string.Empty).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
|
||||||
|
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
|
||||||
|
|
||||||
|
private static string ResolveRedirectUri(HttpContext context, TwitchAuthOptions options)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.RedirectUri))
|
||||||
|
{
|
||||||
|
return options.RedirectUri.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{context.Request.Scheme}://{context.Request.Host}/api/auth/twitch/callback";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeTwitchPurpose(string? purpose)
|
||||||
|
{
|
||||||
|
var normalized = (purpose ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
return normalized is TwitchLoginPurpose or TwitchBindingPurpose ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeReturnUrl(string? returnUrl)
|
||||||
|
{
|
||||||
|
var normalized = (returnUrl ?? "/admin").Trim();
|
||||||
|
return normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
&& !normalized.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
&& !normalized.StartsWith("/auth/twitch/callback", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? normalized
|
||||||
|
: "/admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeFrontendOrigin(
|
||||||
|
string? frontendOrigin,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IWebHostEnvironment environment)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(frontendOrigin?.Trim(), UriKind.Absolute, out var uri)
|
||||||
|
|| uri.Scheme is not ("http" or "https")
|
||||||
|
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var origin = uri.GetLeftPart(UriPartial.Authority);
|
||||||
|
var configuredOrigins = configuration
|
||||||
|
.GetSection(FrontendOptions.SectionName)
|
||||||
|
.Get<FrontendOptions>()?
|
||||||
|
.AllowedOrigins ?? [];
|
||||||
|
var allowedOrigins = configuredOrigins.Length > 0 || !environment.IsDevelopment()
|
||||||
|
? configuredOrigins
|
||||||
|
: ApplicationDefaults.FrontendOrigins;
|
||||||
|
|
||||||
|
return allowedOrigins.Any(item => string.Equals(item.TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase))
|
||||||
|
? origin
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TwitchOAuthState(
|
||||||
|
string Purpose,
|
||||||
|
string ReturnUrl,
|
||||||
|
string FrontendOrigin,
|
||||||
|
string SessionToken);
|
||||||
|
|
||||||
|
private sealed record TwitchTokenResponse(
|
||||||
|
[property: JsonPropertyName("access_token")] string AccessToken);
|
||||||
|
|
||||||
|
private sealed record TwitchUsersResponse(
|
||||||
|
[property: JsonPropertyName("data")] TwitchUserResponseItem[] Data);
|
||||||
|
|
||||||
|
private sealed record TwitchUserResponseItem(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("login")] string Login,
|
||||||
|
[property: JsonPropertyName("display_name")] string DisplayName);
|
||||||
|
}
|
||||||
@@ -21,12 +21,12 @@ public static partial class PublicEndpoints
|
|||||||
|
|
||||||
if (submittedNominations.Length is 0 or > 3)
|
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))
|
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." });
|
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)))
|
var distinctStreamUrls = submittedNominations
|
||||||
{
|
.Select(item => item.StreamUrl)
|
||||||
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var distinctNomineeNames = submittedNominations
|
|
||||||
.Select(item => item.Name)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.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
|
var invalidStreamUrl = submittedNominations
|
||||||
@@ -93,12 +88,10 @@ public static partial class PublicEndpoints
|
|||||||
SeasonId = category.SeasonId,
|
SeasonId = category.SeasonId,
|
||||||
CategoryId = category.Id,
|
CategoryId = category.Id,
|
||||||
SubmittedByTwitchId = submitterId,
|
SubmittedByTwitchId = submitterId,
|
||||||
CandidateText = nomination.Name,
|
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
|
||||||
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
|
||||||
Status = "pending",
|
Status = "pending",
|
||||||
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
|
ReviewNote = $"Stream-Link: {nomination.StreamUrl}",
|
||||||
? null
|
|
||||||
: $"Stream-Link: {nomination.StreamUrl}",
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
@@ -151,7 +144,7 @@ public static partial class PublicEndpoints
|
|||||||
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
|
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)
|
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
|
||||||
{
|
{
|
||||||
@@ -167,15 +160,24 @@ public static partial class PublicEndpoints
|
|||||||
streamUrl = normalizedUrl;
|
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();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (request.Nominees ?? [])
|
return (request.Nominees ?? [])
|
||||||
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
|
.Select(item =>
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
{
|
||||||
|
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();
|
.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public static partial class PublicEndpoints
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
|
||||||
.ThenInclude(category => category.Candidates)
|
.ThenInclude(category => category.Candidates)
|
||||||
|
.OrderByDescending(item => item.Year)
|
||||||
.FirstOrDefaultAsync(item => item.IsCurrent);
|
.FirstOrDefaultAsync(item => item.IsCurrent);
|
||||||
|
|
||||||
if (season is null)
|
if (season is null)
|
||||||
@@ -54,6 +55,22 @@ public static partial class PublicEndpoints
|
|||||||
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
|
||||||
.ToArray();
|
.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 phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
|
||||||
var publicCategories = season.Categories
|
var publicCategories = season.Categories
|
||||||
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
|
.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("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
|
||||||
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", 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)),
|
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
|
||||||
},
|
},
|
||||||
publicCategories
|
publicCategories
|
||||||
@@ -84,6 +101,7 @@ public static partial class PublicEndpoints
|
|||||||
category.MaxNomineesPerUser))
|
category.MaxNomineesPerUser))
|
||||||
.ToArray(),
|
.ToArray(),
|
||||||
winnerPreviewItems,
|
winnerPreviewItems,
|
||||||
|
archiveYears,
|
||||||
new PublicSiteContentDto(
|
new PublicSiteContentDto(
|
||||||
siteSettings.HostDisplayName,
|
siteSettings.HostDisplayName,
|
||||||
siteSettings.HostTagline,
|
siteSettings.HostTagline,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public static partial class PublicEndpoints
|
|||||||
item.Status,
|
item.Status,
|
||||||
Nominee = item.CandidateId != null
|
Nominee = item.CandidateId != null
|
||||||
? item.Candidate!.DisplayName
|
? item.Candidate!.DisplayName
|
||||||
: item.CandidateText,
|
: item.CandidateText ?? item.StreamUrl,
|
||||||
})
|
})
|
||||||
.ToArrayAsync();
|
.ToArrayAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSwaggerGen();
|
services.AddSwaggerGen();
|
||||||
|
|
||||||
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
|
services.Configure<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
|
||||||
|
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddHttpClient();
|
||||||
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
var allowedOrigins = ResolveAllowedOrigins(configuration, environment);
|
||||||
|
|
||||||
var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres");
|
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";
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedFromIp")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
b.Property<string>("EntityId")
|
b.Property<string>("EntityId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
@@ -62,6 +67,11 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(240)
|
.HasMaxLength(240)
|
||||||
.HasColumnType("character varying(240)");
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(400)
|
||||||
|
.HasColumnType("character varying(400)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("AdminAuditEntries");
|
b.ToTable("AdminAuditEntries");
|
||||||
@@ -579,6 +589,10 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("StreamUrl")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
b.Property<string>("SubmittedByTwitchId")
|
b.Property<string>("SubmittedByTwitchId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(120)
|
.HasMaxLength(120)
|
||||||
@@ -838,6 +852,10 @@ namespace Backend.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ContactContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("ContactUrl")
|
b.Property<string>("ContactUrl")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(400)
|
.HasMaxLength(400)
|
||||||
@@ -888,6 +906,10 @@ namespace Backend.Migrations
|
|||||||
.HasMaxLength(160)
|
.HasMaxLength(160)
|
||||||
.HasColumnType("character varying(160)");
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<string>("ImprintContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("ImprintUrl")
|
b.Property<string>("ImprintUrl")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(400)
|
.HasMaxLength(400)
|
||||||
@@ -935,6 +957,10 @@ namespace Backend.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SponsorsContent")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("SponsorsUrl")
|
b.Property<string>("SponsorsUrl")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(400)
|
.HasMaxLength(400)
|
||||||
@@ -948,6 +974,7 @@ namespace Backend.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = 1,
|
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",
|
ContactUrl = "https://vtuber-star-awards.de/kontakt",
|
||||||
DemoLoginDisplayName = "Jayuhime Admin",
|
DemoLoginDisplayName = "Jayuhime Admin",
|
||||||
DemoLoginEmail = "",
|
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.\"}]",
|
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",
|
HostDisplayName = "Jayuhime",
|
||||||
HostTagline = "VTuber & Award Host",
|
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",
|
ImprintUrl = "https://vtuber-star-awards.de/impressum",
|
||||||
MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.",
|
MaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.",
|
||||||
MaintenanceModeEnabled = false,
|
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.",
|
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)),
|
PrivacyPolicyUpdatedAt = new DateTimeOffset(new DateTime(2026, 6, 23, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
|
||||||
PrivacyPolicyUpdatedBy = "seed",
|
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}]",
|
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"
|
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 =>
|
modelBuilder.Entity("Backend.Domain.UserSession", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ namespace Backend.Security;
|
|||||||
public static class AdminRoles
|
public static class AdminRoles
|
||||||
{
|
{
|
||||||
public const string Viewer = "viewer";
|
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 ContentAdmin = "content_admin";
|
||||||
public const string Admin = "admin";
|
public const string Admin = "admin";
|
||||||
public const string Owner = "owner";
|
public const string Owner = "owner";
|
||||||
|
public const string Creator = "creator";
|
||||||
|
|
||||||
public static string Normalize(string? role)
|
public static string Normalize(string? role)
|
||||||
{
|
{
|
||||||
@@ -14,8 +18,19 @@ public static class AdminRoles
|
|||||||
return normalizedRole switch
|
return normalizedRole switch
|
||||||
{
|
{
|
||||||
Owner => Owner,
|
Owner => Owner,
|
||||||
|
Creator => Creator,
|
||||||
Admin => Admin,
|
Admin => Admin,
|
||||||
ContentAdmin => ContentAdmin,
|
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,
|
_ => Viewer,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -23,18 +38,30 @@ public static class AdminRoles
|
|||||||
public static bool IsKnownRole(string? role)
|
public static bool IsKnownRole(string? role)
|
||||||
{
|
{
|
||||||
var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_');
|
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) =>
|
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) =>
|
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) =>
|
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) =>
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using Backend.Data;
|
||||||
|
using Backend.Endpoints;
|
||||||
using Backend.Services;
|
using Backend.Services;
|
||||||
|
|
||||||
namespace Backend.Security;
|
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)
|
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);
|
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);
|
context.HttpContext.SetCurrentSession(session);
|
||||||
return await next(context);
|
return await next(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@
|
|||||||
"TwitchUserId": "jayuhime_admin",
|
"TwitchUserId": "jayuhime_admin",
|
||||||
"DisplayName": "Jayuhime Admin"
|
"DisplayName": "Jayuhime Admin"
|
||||||
},
|
},
|
||||||
|
"TwitchAuth": {
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"RedirectUri": "",
|
||||||
|
"Scope": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
"TwitchUserId": "jayuhime_admin",
|
"TwitchUserId": "jayuhime_admin",
|
||||||
"DisplayName": "Jayuhime Admin"
|
"DisplayName": "Jayuhime Admin"
|
||||||
},
|
},
|
||||||
|
"TwitchAuth": {
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"RedirectUri": "",
|
||||||
|
"Scope": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 708 KiB |
|
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 |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 158 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -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 |
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
|
|||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||||
|
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useAwardsStore } from '../stores/awards'
|
import { useAwardsStore } from '../stores/awards'
|
||||||
import type { AuthRole } from '../types/awards'
|
import type { AuthRole } from '../types/awards'
|
||||||
@@ -22,6 +23,7 @@ const accountOpen = ref(false)
|
|||||||
const deleteConfirm = ref(false)
|
const deleteConfirm = ref(false)
|
||||||
const privacyOpen = ref(false)
|
const privacyOpen = ref(false)
|
||||||
const accountActionError = ref('')
|
const accountActionError = ref('')
|
||||||
|
const accountActionSuccess = ref('')
|
||||||
|
|
||||||
defineExpose({ privacyOpen })
|
defineExpose({ privacyOpen })
|
||||||
|
|
||||||
@@ -36,12 +38,7 @@ const privacyContent = computed(
|
|||||||
const privacyEmail = computed(
|
const privacyEmail = computed(
|
||||||
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
|
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
|
||||||
)
|
)
|
||||||
const privacyContentBlocks = computed(() =>
|
const privacyContentHtml = computed(() => privacyContentToHtml(privacyContent.value))
|
||||||
privacyContent.value
|
|
||||||
.split(/\n{2,}/)
|
|
||||||
.map((block) => block.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
try {
|
try {
|
||||||
@@ -56,6 +53,21 @@ async function doLogout() {
|
|||||||
await router.replace({ name: 'login' })
|
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() {
|
async function deleteMyData() {
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +86,8 @@ async function deleteMyData() {
|
|||||||
function closeAccountModal() {
|
function closeAccountModal() {
|
||||||
accountOpen.value = false
|
accountOpen.value = false
|
||||||
deleteConfirm.value = false
|
deleteConfirm.value = false
|
||||||
|
accountActionError.value = ''
|
||||||
|
accountActionSuccess.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPrivacyModal() {
|
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 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 linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
|
||||||
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -175,9 +190,10 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
|||||||
:privacy-open="privacyOpen"
|
:privacy-open="privacyOpen"
|
||||||
:delete-confirm="deleteConfirm"
|
:delete-confirm="deleteConfirm"
|
||||||
:session="authStore.session"
|
:session="authStore.session"
|
||||||
:privacy-content-blocks="privacyContentBlocks"
|
:privacy-content-html="privacyContentHtml"
|
||||||
:privacy-email="privacyEmail"
|
:privacy-email="privacyEmail"
|
||||||
:account-action-error="accountActionError"
|
:account-action-error="accountActionError"
|
||||||
|
:account-action-success="accountActionSuccess"
|
||||||
:auth-loading="authStore.loading"
|
:auth-loading="authStore.loading"
|
||||||
@close-account="closeAccountModal"
|
@close-account="closeAccountModal"
|
||||||
@close-privacy="closePrivacyModal"
|
@close-privacy="closePrivacyModal"
|
||||||
@@ -185,6 +201,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
|||||||
@request-delete="requestAccountDeletion"
|
@request-delete="requestAccountDeletion"
|
||||||
@cancel-delete="cancelAccountDeletion"
|
@cancel-delete="cancelAccountDeletion"
|
||||||
@logout="doLogout"
|
@logout="doLogout"
|
||||||
|
@bind-team-twitch="bindTeamTwitch"
|
||||||
@confirm-delete="deleteMyData"
|
@confirm-delete="deleteMyData"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ const props = defineProps<{
|
|||||||
privacyOpen: boolean
|
privacyOpen: boolean
|
||||||
deleteConfirm: boolean
|
deleteConfirm: boolean
|
||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
privacyContentBlocks: string[]
|
privacyContentHtml: string
|
||||||
privacyEmail: string
|
privacyEmail: string
|
||||||
accountActionError: string
|
accountActionError: string
|
||||||
|
accountActionSuccess: string
|
||||||
authLoading: boolean
|
authLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -21,11 +22,14 @@ defineEmits<{
|
|||||||
'request-delete': []
|
'request-delete': []
|
||||||
'cancel-delete': []
|
'cancel-delete': []
|
||||||
logout: []
|
logout: []
|
||||||
|
'bind-team-twitch': []
|
||||||
'confirm-delete': []
|
'confirm-delete': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
|
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
|
||||||
const role = computed(() => props.session?.role ?? 'viewer')
|
const role = computed(() => props.session?.role ?? 'viewer')
|
||||||
|
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
||||||
|
const canBindTwitch = computed(() => isTeamSession.value && !props.session?.mustChangePassword)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -67,6 +71,14 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
|||||||
<span style="color:#6f6685;">Rolle</span>
|
<span style="color:#6f6685;">Rolle</span>
|
||||||
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
|
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
|
||||||
</div>
|
</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;">
|
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||||
<span style="color:#6f6685;">Einreichungen</span>
|
<span style="color:#6f6685;">Einreichungen</span>
|
||||||
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</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>
|
</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
|
<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%;"
|
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')"
|
@click="$emit('open-privacy')"
|
||||||
@@ -142,15 +182,9 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
|||||||
</div>
|
</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>
|
<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>
|
||||||
<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;">
|
<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="privacyContentBlocks.length">
|
<template v-if="privacyContentHtml">
|
||||||
<p
|
<div class="app-shell-privacy-content" v-html="privacyContentHtml" />
|
||||||
v-for="(block, index) in privacyContentBlocks"
|
|
||||||
:key="`shell-privacy-${index}`"
|
|
||||||
style="margin:0;white-space:pre-wrap;"
|
|
||||||
>
|
|
||||||
{{ block }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
|
<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>
|
<p style="margin:0;">Die Datenschutzerklärung wird aus der Landingpage-Konfiguration geladen.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</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>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
|
<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>
|
<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>
|
||||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||||
<Link2 class="h-6 w-6 text-violet-500" />
|
<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">
|
<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" />
|
<Save class="h-4 w-4" />
|
||||||
{{ saving ? 'Speichert ...' : 'Links speichern' }}
|
{{ saving ? 'Speichert ...' : 'Footer speichern' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<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>
|
</label>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link2, Save } from '@lucide/vue'
|
import { Eye, Link2, Save } from '@lucide/vue'
|
||||||
|
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import Card from '../ui/Card.vue'
|
||||||
|
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||||
import type { AdminContentForm } from './adminContentTypes'
|
import type { AdminContentForm } from './adminContentTypes'
|
||||||
|
|
||||||
|
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
form: AdminContentForm
|
form: AdminContentForm
|
||||||
saving: boolean
|
saving: boolean
|
||||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'open-preview': [key: FooterPreviewKey]
|
||||||
|
}>()
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
return props.saveSiteSettings('Footer & Kontakt')
|
return props.saveSiteSettings('Footer & Kontakt')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,13 @@
|
|||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<p
|
<div
|
||||||
v-for="(block, index) in blocks"
|
v-if="contentHtml"
|
||||||
:key="`privacy-modal-block-${index}`"
|
class="privacy-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
|
||||||
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
|
v-html="contentHtml"
|
||||||
>
|
/>
|
||||||
{{ block }}
|
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
|
||||||
</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">
|
|
||||||
Noch kein Datenschutztext eingetragen.
|
Noch kein Datenschutztext eingetragen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +41,7 @@ import { X } from '@lucide/vue'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
blocks: string[]
|
contentHtml: string
|
||||||
updatedLabel: string
|
updatedLabel: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -51,3 +49,25 @@ defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</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>
|
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="mt-6 block space-y-2">
|
<AdminRichTextEditor
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
|
|
||||||
<textarea
|
|
||||||
v-model="form.privacyPolicyContent"
|
v-model="form.privacyPolicyContent"
|
||||||
rows="24"
|
class="mt-6"
|
||||||
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"
|
label="Datenschutz Inhalt"
|
||||||
placeholder="Datenschutztext..."
|
placeholder="Datenschutztext..."
|
||||||
|
min-height-class="min-h-[560px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,6 +47,7 @@ import { Eye, Save, ShieldCheck } from '@lucide/vue'
|
|||||||
|
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import Card from '../ui/Card.vue'
|
||||||
|
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||||
import type { AdminContentForm } from './adminContentTypes'
|
import type { AdminContentForm } from './adminContentTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<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 { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
import Card from '../ui/Card.vue'
|
import Card from '../ui/Card.vue'
|
||||||
|
import Modal from '../ui/Modal.vue'
|
||||||
|
import PasswordField from '../ui/PasswordField.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
form: AdminOperationalSettingsForm
|
form: AdminOperationalSettingsForm
|
||||||
@@ -14,10 +17,16 @@ defineProps<{
|
|||||||
error: string
|
error: string
|
||||||
success: string
|
success: string
|
||||||
demoPassword: string
|
demoPassword: string
|
||||||
|
twitchClientSecret: string
|
||||||
demoPasswordHint: string
|
demoPasswordHint: string
|
||||||
demoPasswordSet: boolean
|
demoPasswordSet: boolean
|
||||||
demoManagedByDatabase: boolean
|
demoManagedByDatabase: boolean
|
||||||
demoCredentialsComplete: boolean
|
demoCredentialsComplete: boolean
|
||||||
|
twitchSecretHint: string
|
||||||
|
twitchClientSecretSet: boolean
|
||||||
|
twitchAuthConfigured: boolean
|
||||||
|
twitchAuthManagedByDatabase: boolean
|
||||||
|
twitchAuthComplete: boolean
|
||||||
summary: AdminSettingsStatusSummary[]
|
summary: AdminSettingsStatusSummary[]
|
||||||
dirty: boolean
|
dirty: boolean
|
||||||
canManage: boolean
|
canManage: boolean
|
||||||
@@ -26,10 +35,17 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
save: []
|
save: []
|
||||||
'update:demoPassword': [value: string]
|
'update:demoPassword': [value: string]
|
||||||
|
'update:twitchClientSecret': [value: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function readInput(event: Event) {
|
const twitchModalOpen = ref(false)
|
||||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
|
||||||
|
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) {
|
function toneClasses(tone: AdminSettingsTone) {
|
||||||
@@ -89,7 +105,7 @@ function toneClasses(tone: AdminSettingsTone) {
|
|||||||
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
|
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-3">
|
<div class="grid gap-3 md:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
v-for="item in summary"
|
v-for="item in summary"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
@@ -102,6 +118,46 @@ function toneClasses(tone: AdminSettingsTone) {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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)]">
|
<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">
|
<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>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
|
<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>
|
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
@@ -207,4 +271,90 @@ function toneClasses(tone: AdminSettingsTone) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</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="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
<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">
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -126,7 +126,7 @@ const emit = defineEmits<{
|
|||||||
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
||||||
<ul class="mt-2 space-y-1">
|
<ul class="mt-2 space-y-1">
|
||||||
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 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 class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<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">
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
|
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
|
||||||
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const emit = defineEmits<{
|
|||||||
ID {{ nomination.id }}
|
ID {{ nomination.id }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p class="mt-1 truncate text-sm text-slate-500">
|
||||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||||
</p>
|
</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" />
|
<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>
|
||||||
<label class="space-y-2">
|
<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" />
|
<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>
|
||||||
<label class="space-y-2">
|
<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" />
|
<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>
|
||||||
<label class="space-y-2 sm:col-span-2">
|
<label class="space-y-2 sm:col-span-2">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const phaseCards = computed(() =>
|
|||||||
...phase,
|
...phase,
|
||||||
ordinal: String(index + 1).padStart(2, '0'),
|
ordinal: String(index + 1).padStart(2, '0'),
|
||||||
finalLocked,
|
finalLocked,
|
||||||
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
|
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Phase aktivieren',
|
||||||
actionTitle: isCompletedPhase
|
actionTitle: isCompletedPhase
|
||||||
? showHasPassed
|
? showHasPassed
|
||||||
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
|
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<span>
|
<span>
|
||||||
<span class="block font-semibold text-slate-800">Public-Kontext</span>
|
<span class="block font-semibold text-slate-800">Public-Kontext</span>
|
||||||
<span class="mt-1 block text-sm leading-5 text-slate-500">
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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="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>
|
<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">
|
<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" />
|
<Trash2 class="h-4 w-4" />
|
||||||
Löschen
|
Löschen
|
||||||
</Button>
|
</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">
|
<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" />
|
<CheckCircle2 class="h-4 w-4" />
|
||||||
{{ completing ? 'Schliesst ...' : 'Beenden' }}
|
{{ completing ? 'Schliesst ...' : 'Beenden' }}
|
||||||
@@ -130,7 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
|
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
|
||||||
@@ -155,6 +159,7 @@ defineProps<{
|
|||||||
canCompleteSelectedSeason: boolean
|
canCompleteSelectedSeason: boolean
|
||||||
selectedSeasonIsCurrent: boolean
|
selectedSeasonIsCurrent: boolean
|
||||||
openDeleteSeasonModal: () => void
|
openDeleteSeasonModal: () => void
|
||||||
|
activatePublicSeason: () => Promise<boolean | void> | boolean | void
|
||||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||||
completeSeason: () => Promise<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 class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
|
<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">
|
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
|
||||||
<p class="text-xs leading-5 text-slate-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ export type AdminContentForm = {
|
|||||||
privacyEmail: string
|
privacyEmail: string
|
||||||
privacyPolicyContent: string
|
privacyPolicyContent: string
|
||||||
imprintUrl: string
|
imprintUrl: string
|
||||||
|
imprintContent: string
|
||||||
contactUrl: string
|
contactUrl: string
|
||||||
|
contactContent: string
|
||||||
sponsorsUrl: string
|
sponsorsUrl: string
|
||||||
|
sponsorsContent: string
|
||||||
socialLinks: SocialLinkForm[]
|
socialLinks: SocialLinkForm[]
|
||||||
faq: FaqFormItem[]
|
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 =
|
export type SeasonDateField =
|
||||||
| 'nominationStartsAt'
|
| 'nominationStartsAt'
|
||||||
@@ -58,9 +58,9 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
|
|||||||
editable: true,
|
editable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'review',
|
key: 'preparation',
|
||||||
title: 'Review & Auswertung',
|
title: 'Aufbereitung',
|
||||||
description: 'Team prüft Votes, Clips und Ergebnisse.',
|
description: 'Pause vor der Show: Ergebnisse, Clips und Ablauf vorbereiten.',
|
||||||
start: 'reviewStartsAt',
|
start: 'reviewStartsAt',
|
||||||
end: 'reviewEndsAt',
|
end: 'reviewEndsAt',
|
||||||
editable: true,
|
editable: true,
|
||||||
@@ -116,7 +116,7 @@ export function normalizePhaseKey(value: string): PhaseKey | string {
|
|||||||
const phase = value.trim().toLowerCase()
|
const phase = value.trim().toLowerCase()
|
||||||
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
|
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
|
||||||
if (phase.includes('show')) return 'show'
|
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('vot')) return 'voting'
|
||||||
if (phase.includes('nomin')) return 'nomination'
|
if (phase.includes('nomin')) return 'nomination'
|
||||||
return phase
|
return phase
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface AdminOperationalSettingsForm {
|
|||||||
demoLoginIdentifier: string
|
demoLoginIdentifier: string
|
||||||
demoLoginTwitchUserId: string
|
demoLoginTwitchUserId: string
|
||||||
demoLoginDisplayName: string
|
demoLoginDisplayName: string
|
||||||
|
twitchClientId: string
|
||||||
|
twitchRedirectUri: string
|
||||||
|
twitchScope: string
|
||||||
maintenanceModeEnabled: boolean
|
maintenanceModeEnabled: boolean
|
||||||
maintenanceTitle: string
|
maintenanceTitle: string
|
||||||
maintenanceMessage: string
|
maintenanceMessage: string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
simpleIconForKey,
|
simpleIconForKey,
|
||||||
socialIconOptionForKey,
|
socialIconOptionForKey,
|
||||||
} from '../../lib/socialIcons'
|
} from '../../lib/socialIcons'
|
||||||
|
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||||
|
|
||||||
@@ -16,8 +17,11 @@ function createEmptyForm(): AdminContentForm {
|
|||||||
privacyEmail: '',
|
privacyEmail: '',
|
||||||
privacyPolicyContent: '',
|
privacyPolicyContent: '',
|
||||||
imprintUrl: '',
|
imprintUrl: '',
|
||||||
|
imprintContent: '',
|
||||||
contactUrl: '',
|
contactUrl: '',
|
||||||
|
contactContent: '',
|
||||||
sponsorsUrl: '',
|
sponsorsUrl: '',
|
||||||
|
sponsorsContent: '',
|
||||||
socialLinks: [],
|
socialLinks: [],
|
||||||
faq: [],
|
faq: [],
|
||||||
}
|
}
|
||||||
@@ -74,8 +78,11 @@ export function useAdminContentManager() {
|
|||||||
form.privacyEmail = settings.privacyEmail
|
form.privacyEmail = settings.privacyEmail
|
||||||
form.privacyPolicyContent = settings.privacyPolicyContent
|
form.privacyPolicyContent = settings.privacyPolicyContent
|
||||||
form.imprintUrl = settings.imprintUrl
|
form.imprintUrl = settings.imprintUrl
|
||||||
|
form.imprintContent = settings.imprintContent
|
||||||
form.contactUrl = settings.contactUrl
|
form.contactUrl = settings.contactUrl
|
||||||
|
form.contactContent = settings.contactContent
|
||||||
form.sponsorsUrl = settings.sponsorsUrl
|
form.sponsorsUrl = settings.sponsorsUrl
|
||||||
|
form.sponsorsContent = settings.sponsorsContent
|
||||||
form.socialLinks = settings.socialLinks.map((item) => ({
|
form.socialLinks = settings.socialLinks.map((item) => ({
|
||||||
label: item.label ?? '',
|
label: item.label ?? '',
|
||||||
platform: item.platform ?? '',
|
platform: item.platform ?? '',
|
||||||
@@ -92,12 +99,7 @@ export function useAdminContentManager() {
|
|||||||
{ immediate: true, deep: true },
|
{ immediate: true, deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const privacyPreviewBlocks = computed(() =>
|
const privacyPreviewHtml = computed(() => privacyContentToHtml(form.privacyPolicyContent))
|
||||||
form.privacyPolicyContent
|
|
||||||
.split(/\n{2,}/)
|
|
||||||
.map((block) => block.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
|
|
||||||
const privacyUpdatedLabel = computed(() => {
|
const privacyUpdatedLabel = computed(() => {
|
||||||
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
|
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
|
||||||
@@ -241,10 +243,13 @@ export function useAdminContentManager() {
|
|||||||
hostTagline: form.hostTagline,
|
hostTagline: form.hostTagline,
|
||||||
newsletterUrl: form.newsletterUrl,
|
newsletterUrl: form.newsletterUrl,
|
||||||
privacyEmail: form.privacyEmail,
|
privacyEmail: form.privacyEmail,
|
||||||
privacyPolicyContent: form.privacyPolicyContent,
|
privacyPolicyContent: privacyContentForStorage(form.privacyPolicyContent),
|
||||||
imprintUrl: form.imprintUrl,
|
imprintUrl: form.imprintUrl,
|
||||||
|
imprintContent: privacyContentForStorage(form.imprintContent),
|
||||||
contactUrl: form.contactUrl,
|
contactUrl: form.contactUrl,
|
||||||
|
contactContent: privacyContentForStorage(form.contactContent),
|
||||||
sponsorsUrl: form.sponsorsUrl,
|
sponsorsUrl: form.sponsorsUrl,
|
||||||
|
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
|
||||||
socialLinks: form.socialLinks
|
socialLinks: form.socialLinks
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: item.label.trim(),
|
label: item.label.trim(),
|
||||||
@@ -277,7 +282,7 @@ export function useAdminContentManager() {
|
|||||||
saveError,
|
saveError,
|
||||||
privacyPreviewOpen,
|
privacyPreviewOpen,
|
||||||
iconUploadError,
|
iconUploadError,
|
||||||
privacyPreviewBlocks,
|
privacyPreviewHtml,
|
||||||
privacyUpdatedLabel,
|
privacyUpdatedLabel,
|
||||||
addSocialLink,
|
addSocialLink,
|
||||||
removeSocialLink,
|
removeSocialLink,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/v
|
|||||||
|
|
||||||
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
|
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
|
||||||
import { useAwardsStore } from '../../stores/awards'
|
import { useAwardsStore } from '../../stores/awards'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
const metricToneMap = {
|
const metricToneMap = {
|
||||||
Nominierungen: {
|
Nominierungen: {
|
||||||
@@ -29,6 +30,7 @@ const metricToneMap = {
|
|||||||
|
|
||||||
export function useAdminDashboardOverview() {
|
export function useAdminDashboardOverview() {
|
||||||
const store = useAwardsStore()
|
const store = useAwardsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const metrics = computed(() => store.admin.metrics)
|
const metrics = computed(() => store.admin.metrics)
|
||||||
const activities = computed(() => store.admin.activities)
|
const activities = computed(() => store.admin.activities)
|
||||||
@@ -108,6 +110,17 @@ export function useAdminDashboardOverview() {
|
|||||||
icon: ShieldAlert,
|
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(() => [
|
const priorityActions = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Reviews bearbeiten',
|
label: 'Reviews bearbeiten',
|
||||||
@@ -141,7 +154,7 @@ export function useAdminDashboardOverview() {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
tone: 'emerald',
|
tone: 'emerald',
|
||||||
},
|
},
|
||||||
])
|
].filter((item) => canOpenAdminPath(item.to)))
|
||||||
const operationChecks = computed(() => {
|
const operationChecks = computed(() => {
|
||||||
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
|
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
|
||||||
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
|
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||||
@@ -172,7 +185,7 @@ export function useAdminDashboardOverview() {
|
|||||||
state: openRiskCount.value === 0 ? 'ok' : 'danger',
|
state: openRiskCount.value === 0 ? 'ok' : 'danger',
|
||||||
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
|
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
|
||||||
},
|
},
|
||||||
]
|
].filter((item) => canOpenAdminPath(item.to))
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export function useAdminOperationalSettings() {
|
|||||||
const demoPasswordSet = ref(false)
|
const demoPasswordSet = ref(false)
|
||||||
const demoManagedByDatabase = ref(false)
|
const demoManagedByDatabase = ref(false)
|
||||||
const demoPasswordInput = ref('')
|
const demoPasswordInput = ref('')
|
||||||
|
const twitchClientSecretSet = ref(false)
|
||||||
|
const twitchAuthConfigured = ref(false)
|
||||||
|
const twitchAuthManagedByDatabase = ref(false)
|
||||||
|
const twitchClientSecretInput = ref('')
|
||||||
const savedOperationalSnapshot = ref('')
|
const savedOperationalSnapshot = ref('')
|
||||||
|
|
||||||
const operationalForm = reactive<AdminOperationalSettingsForm>({
|
const operationalForm = reactive<AdminOperationalSettingsForm>({
|
||||||
@@ -23,6 +27,9 @@ export function useAdminOperationalSettings() {
|
|||||||
demoLoginIdentifier: '',
|
demoLoginIdentifier: '',
|
||||||
demoLoginTwitchUserId: '',
|
demoLoginTwitchUserId: '',
|
||||||
demoLoginDisplayName: '',
|
demoLoginDisplayName: '',
|
||||||
|
twitchClientId: '',
|
||||||
|
twitchRedirectUri: '',
|
||||||
|
twitchScope: '',
|
||||||
maintenanceModeEnabled: false,
|
maintenanceModeEnabled: false,
|
||||||
maintenanceTitle: fallbackMaintenanceTitle,
|
maintenanceTitle: fallbackMaintenanceTitle,
|
||||||
maintenanceMessage: fallbackMaintenanceMessage,
|
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[]>(() => [
|
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
|
||||||
{
|
{
|
||||||
label: 'Demo Login',
|
label: 'Demo Login',
|
||||||
@@ -73,6 +97,14 @@ export function useAdminOperationalSettings() {
|
|||||||
: 'Öffentliche Seiten werden normal ausgeliefert.',
|
: 'Öffentliche Seiten werden normal ausgeliefert.',
|
||||||
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
|
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',
|
label: 'Passwort',
|
||||||
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
|
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
|
||||||
@@ -91,12 +123,19 @@ export function useAdminOperationalSettings() {
|
|||||||
operationalForm.demoLoginIdentifier = response.demoLoginEmail
|
operationalForm.demoLoginIdentifier = response.demoLoginEmail
|
||||||
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
|
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
|
||||||
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
|
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
|
||||||
|
operationalForm.twitchClientId = response.twitchClientId
|
||||||
|
operationalForm.twitchRedirectUri = response.twitchRedirectUri
|
||||||
|
operationalForm.twitchScope = response.twitchScope
|
||||||
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
|
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
|
||||||
operationalForm.maintenanceTitle = response.maintenanceTitle
|
operationalForm.maintenanceTitle = response.maintenanceTitle
|
||||||
operationalForm.maintenanceMessage = response.maintenanceMessage
|
operationalForm.maintenanceMessage = response.maintenanceMessage
|
||||||
demoPasswordSet.value = response.demoLoginPasswordSet
|
demoPasswordSet.value = response.demoLoginPasswordSet
|
||||||
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
|
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
|
||||||
|
twitchClientSecretSet.value = response.twitchClientSecretSet
|
||||||
|
twitchAuthConfigured.value = response.twitchAuthConfigured
|
||||||
|
twitchAuthManagedByDatabase.value = response.twitchAuthManagedByDatabase
|
||||||
demoPasswordInput.value = ''
|
demoPasswordInput.value = ''
|
||||||
|
twitchClientSecretInput.value = ''
|
||||||
rememberSavedOperationalSettings()
|
rememberSavedOperationalSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +145,10 @@ export function useAdminOperationalSettings() {
|
|||||||
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
|
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
|
||||||
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
|
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
|
||||||
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
|
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
|
||||||
|
twitchClientId: operationalForm.twitchClientId,
|
||||||
|
twitchRedirectUri: operationalForm.twitchRedirectUri,
|
||||||
|
twitchScope: operationalForm.twitchScope,
|
||||||
|
twitchClientSecretInput: twitchClientSecretInput.value,
|
||||||
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
|
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
|
||||||
maintenanceTitle: operationalForm.maintenanceTitle,
|
maintenanceTitle: operationalForm.maintenanceTitle,
|
||||||
maintenanceMessage: operationalForm.maintenanceMessage,
|
maintenanceMessage: operationalForm.maintenanceMessage,
|
||||||
@@ -137,8 +180,8 @@ export function useAdminOperationalSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateOperationalSettings() {
|
function validateOperationalSettings() {
|
||||||
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
|
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 10) {
|
||||||
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
|
operationalError.value = 'Das Demo-Passwort muss mindestens 10 Zeichen lang sein.'
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +190,24 @@ export function useAdminOperationalSettings() {
|
|||||||
return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +225,14 @@ export function useAdminOperationalSettings() {
|
|||||||
...operationalForm,
|
...operationalForm,
|
||||||
demoLoginEmail: operationalForm.demoLoginIdentifier,
|
demoLoginEmail: operationalForm.demoLoginIdentifier,
|
||||||
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
|
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
|
||||||
|
twitchClientSecret: twitchClientSecretInput.value.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
demoPasswordSet.value = result.demoLoginPasswordSet
|
demoPasswordSet.value = result.demoLoginPasswordSet
|
||||||
demoManagedByDatabase.value = true
|
demoManagedByDatabase.value = true
|
||||||
|
twitchClientSecretSet.value = result.twitchClientSecretSet
|
||||||
demoPasswordInput.value = ''
|
demoPasswordInput.value = ''
|
||||||
|
twitchClientSecretInput.value = ''
|
||||||
await loadOperationalSettings({ silent: true })
|
await loadOperationalSettings({ silent: true })
|
||||||
clearSiteStatusCache()
|
clearSiteStatusCache()
|
||||||
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
|
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
|
||||||
@@ -187,6 +251,10 @@ export function useAdminOperationalSettings() {
|
|||||||
operationalForm.demoLoginIdentifier,
|
operationalForm.demoLoginIdentifier,
|
||||||
operationalForm.demoLoginTwitchUserId,
|
operationalForm.demoLoginTwitchUserId,
|
||||||
operationalForm.demoLoginDisplayName,
|
operationalForm.demoLoginDisplayName,
|
||||||
|
operationalForm.twitchClientId,
|
||||||
|
operationalForm.twitchRedirectUri,
|
||||||
|
operationalForm.twitchScope,
|
||||||
|
twitchClientSecretInput.value,
|
||||||
operationalForm.maintenanceModeEnabled,
|
operationalForm.maintenanceModeEnabled,
|
||||||
operationalForm.maintenanceTitle,
|
operationalForm.maintenanceTitle,
|
||||||
operationalForm.maintenanceMessage,
|
operationalForm.maintenanceMessage,
|
||||||
@@ -210,8 +278,14 @@ export function useAdminOperationalSettings() {
|
|||||||
demoPasswordSet,
|
demoPasswordSet,
|
||||||
demoManagedByDatabase,
|
demoManagedByDatabase,
|
||||||
demoPasswordInput,
|
demoPasswordInput,
|
||||||
|
twitchClientSecretSet,
|
||||||
|
twitchAuthConfigured,
|
||||||
|
twitchAuthManagedByDatabase,
|
||||||
|
twitchClientSecretInput,
|
||||||
demoPasswordHint,
|
demoPasswordHint,
|
||||||
|
twitchSecretHint,
|
||||||
demoCredentialsComplete,
|
demoCredentialsComplete,
|
||||||
|
twitchAuthComplete,
|
||||||
operationalSummary,
|
operationalSummary,
|
||||||
hasUnsavedOperationalChanges,
|
hasUnsavedOperationalChanges,
|
||||||
loadOperationalSettings,
|
loadOperationalSettings,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useAdminReviewsManager() {
|
|||||||
const query = reviewFilter.value.trim().toLowerCase()
|
const query = reviewFilter.value.trim().toLowerCase()
|
||||||
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||||
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
|
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
|
||||||
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, extractNominationStreamUrl(nomination)]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query)),
|
.includes(query)),
|
||||||
@@ -74,7 +74,7 @@ export function useAdminReviewsManager() {
|
|||||||
return false
|
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
|
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
|
||||||
return sameName || sameStreamUrl
|
return sameName || sameStreamUrl
|
||||||
})
|
})
|
||||||
@@ -84,9 +84,13 @@ export function useAdminReviewsManager() {
|
|||||||
if (!selectedNomination.value) return null
|
if (!selectedNomination.value) return null
|
||||||
|
|
||||||
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
||||||
|
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
|
||||||
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
|
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||||
nomination.categoryId === selectedNomination.value?.categoryId &&
|
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 submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
|
||||||
const platforms = new Set(
|
const platforms = new Set(
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function useAdminSeasonManager() {
|
|||||||
const selectedSeason = computed(() =>
|
const selectedSeason = computed(() =>
|
||||||
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
|
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 readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
|
||||||
const publicReadinessIssues = computed(() =>
|
const publicReadinessIssues = computed(() =>
|
||||||
readinessItems.value
|
readinessItems.value
|
||||||
@@ -80,6 +80,9 @@ export function useAdminSeasonManager() {
|
|||||||
.map((item) => item.note),
|
.map((item) => item.note),
|
||||||
)
|
)
|
||||||
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
|
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
|
||||||
|
const visibleArchiveReadinessIssues = computed(() =>
|
||||||
|
normalizePhaseKey(form.currentPhase) === 'completed' ? archiveReadinessIssues.value : [],
|
||||||
|
)
|
||||||
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
|
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
|
||||||
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
|
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
|
||||||
const canCompleteSelectedSeason = computed(() =>
|
const canCompleteSelectedSeason = computed(() =>
|
||||||
@@ -173,7 +176,7 @@ export function useAdminSeasonManager() {
|
|||||||
createForm.votingStartsAt = `${year}-08-25`
|
createForm.votingStartsAt = `${year}-08-25`
|
||||||
createForm.votingEndsAt = `${year}-09-11`
|
createForm.votingEndsAt = `${year}-09-11`
|
||||||
createForm.reviewStartsAt = `${year}-09-12`
|
createForm.reviewStartsAt = `${year}-09-12`
|
||||||
createForm.reviewEndsAt = `${year}-09-15`
|
createForm.reviewEndsAt = `${year}-09-19`
|
||||||
createForm.showDate = `${year}-09-20`
|
createForm.showDate = `${year}-09-20`
|
||||||
createForm.showStartsAt = '20:00'
|
createForm.showStartsAt = '20:00'
|
||||||
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
|
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
|
||||||
@@ -229,6 +232,19 @@ export function useAdminSeasonManager() {
|
|||||||
return persistSeason('Jahresstatus gespeichert.')
|
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) {
|
async function activatePhase(phase: string) {
|
||||||
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
|
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
|
||||||
return
|
return
|
||||||
@@ -392,7 +408,7 @@ export function useAdminSeasonManager() {
|
|||||||
selectedSeason,
|
selectedSeason,
|
||||||
readinessItems,
|
readinessItems,
|
||||||
publicReadinessIssues,
|
publicReadinessIssues,
|
||||||
archiveReadinessIssues,
|
archiveReadinessIssues: visibleArchiveReadinessIssues,
|
||||||
createPublicReadinessIssues,
|
createPublicReadinessIssues,
|
||||||
canActivatePublic,
|
canActivatePublic,
|
||||||
phasePresets,
|
phasePresets,
|
||||||
@@ -404,6 +420,7 @@ export function useAdminSeasonManager() {
|
|||||||
latestSeasonAuditMeta,
|
latestSeasonAuditMeta,
|
||||||
canCreate,
|
canCreate,
|
||||||
activatePhase,
|
activatePhase,
|
||||||
|
activatePublicSeason,
|
||||||
openCreateModal,
|
openCreateModal,
|
||||||
saveSeason,
|
saveSeason,
|
||||||
completeSeason,
|
completeSeason,
|
||||||
@@ -477,7 +494,7 @@ function buildReadinessItems(
|
|||||||
label: 'Reviews',
|
label: 'Reviews',
|
||||||
note: detail.pendingNominations.length === 0
|
note: detail.pendingNominations.length === 0
|
||||||
? 'Keine offenen Nominierungsreviews.'
|
? '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,
|
complete: detail.pendingNominations.length === 0,
|
||||||
blocking: false,
|
blocking: false,
|
||||||
to: '/admin/nominations?review=1',
|
to: '/admin/nominations?review=1',
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export function useAdminSettingsOverview() {
|
|||||||
siteSettings.value.sponsorsUrl,
|
siteSettings.value.sponsorsUrl,
|
||||||
siteSettings.value.newsletterUrl,
|
siteSettings.value.newsletterUrl,
|
||||||
].filter((url) => url.trim()).length)
|
].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[]>(() => [
|
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
|
||||||
{
|
{
|
||||||
label: 'Host',
|
label: 'Host',
|
||||||
@@ -57,8 +62,8 @@ export function useAdminSettingsOverview() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Footer Links',
|
label: 'Footer Links',
|
||||||
value: configuredFooterLinks.value === 4,
|
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
|
||||||
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
|
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
|
||||||
icon: Link2,
|
icon: Link2,
|
||||||
to: '/admin/content',
|
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">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
privacyModalOpen: boolean
|
privacyModalOpen: boolean
|
||||||
privacyContentBlocks: string[]
|
privacyContentHtml: string
|
||||||
onClosePrivacy: () => void
|
onClosePrivacy: () => void
|
||||||
privacyModalStop: (event: Event) => void
|
privacyModalStop: (event: Event) => void
|
||||||
accountModalOpen: boolean
|
accountModalOpen: boolean
|
||||||
@@ -32,14 +32,11 @@ const props = defineProps<{
|
|||||||
</div>
|
</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>
|
<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>
|
||||||
<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;">
|
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||||
<p
|
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
|
||||||
v-for="(block, index) in props.privacyContentBlocks"
|
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
|
||||||
:key="`privacy-block-${index}`"
|
Datenschutzerklärung wird geladen.
|
||||||
style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;white-space:pre-wrap;"
|
</div>
|
||||||
>
|
|
||||||
{{ block }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,3 +96,25 @@ const props = defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</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)"
|
:style="props.archiveYearButtonStyle(year.active)"
|
||||||
>
|
>
|
||||||
<span>{{ year.label }}</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const nominationCatValue = ref(0)
|
const nominationCatValue = ref(0)
|
||||||
const clipCatValue = ref(0)
|
const clipCatValue = ref(0)
|
||||||
const clipNomValue = ref(0)
|
const clipNomQuery = ref('')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.catOptions,
|
() => props.catOptions,
|
||||||
@@ -61,21 +61,13 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.clipNomOptions,
|
|
||||||
(options) => {
|
|
||||||
clipNomValue.value = resolveOptionValue(clipNomValue.value, options)
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
||||||
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClipCategoryChange(value: number) {
|
function handleClipCategoryChange(value: number) {
|
||||||
clipCatValue.value = value
|
clipCatValue.value = value
|
||||||
clipNomValue.value = 0
|
clipNomQuery.value = ''
|
||||||
notifyClipCategoryChange(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;">
|
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
|
||||||
<div>
|
<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>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
<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"
|
:options="props.catOptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
|
<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;" />
|
<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;">Twitch, Kick, YouTube oder ein anderer offizieller Kanal-Link.</p>
|
<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>
|
</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);">
|
<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' }}
|
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
|
||||||
@@ -166,7 +154,7 @@ function notifyClipCategoryChange(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
@@ -180,13 +168,19 @@ function notifyClipCategoryChange(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||||
<HomeSelectDropdown
|
<input
|
||||||
v-model="clipNomValue"
|
v-model="clipNomQuery"
|
||||||
data-ref="clipNomRef"
|
data-dc-ref="clipNomSearchRef"
|
||||||
label="VTuber fuer Clip auswaehlen"
|
type="text"
|
||||||
placeholder="Noch keine Kandidat:innen"
|
list="clip-nominee-options"
|
||||||
:options="props.clipNomOptions"
|
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>
|
</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 class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
|
||||||
<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>
|
<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>
|
||||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||||
<div>
|
<div>
|
||||||
@@ -249,13 +243,19 @@ function notifyClipCategoryChange(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||||
<HomeSelectDropdown
|
<input
|
||||||
v-model="clipNomValue"
|
v-model="clipNomQuery"
|
||||||
data-ref="clipNomRef"
|
data-dc-ref="clipNomSearchRef"
|
||||||
label="VTuber fuer Clip auswaehlen"
|
type="text"
|
||||||
placeholder="Noch keine Kandidat:innen"
|
list="clip-nominee-options"
|
||||||
:options="props.clipNomOptions"
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ const {
|
|||||||
communitySocialLinks,
|
communitySocialLinks,
|
||||||
footerLinks,
|
footerLinks,
|
||||||
faqItems,
|
faqItems,
|
||||||
privacyContentBlocks,
|
privacyContentHtml,
|
||||||
publicStreamUrl,
|
publicStreamUrl,
|
||||||
displayCategories,
|
displayCategories,
|
||||||
nominationPhase,
|
nominationPhase,
|
||||||
votingPhase,
|
votingPhase,
|
||||||
reviewPhase,
|
preparationPhase,
|
||||||
showPhase,
|
showPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
showCountdown,
|
showCountdown,
|
||||||
@@ -145,7 +145,7 @@ const {
|
|||||||
const previewPhaseButtons = [
|
const previewPhaseButtons = [
|
||||||
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
|
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
|
||||||
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
|
{ 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' },
|
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
archiveYear,
|
archiveYear,
|
||||||
nominationPhase,
|
nominationPhase,
|
||||||
votingPhase,
|
votingPhase,
|
||||||
reviewPhase,
|
preparationPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
initializeHomeInteractions,
|
initializeHomeInteractions,
|
||||||
submitNomination,
|
submitNomination,
|
||||||
@@ -250,7 +250,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
<HomeTimelineSection
|
<HomeTimelineSection
|
||||||
:nomination-phase="nominationPhase"
|
:nomination-phase="nominationPhase"
|
||||||
:voting-phase="votingPhase"
|
:voting-phase="votingPhase"
|
||||||
:review-phase="reviewPhase"
|
:preparation-phase="preparationPhase"
|
||||||
:show-phase="showPhase"
|
:show-phase="showPhase"
|
||||||
:completed-phase="completedPhase"
|
:completed-phase="completedPhase"
|
||||||
:timeline-line-style="timelineLineStyle"
|
:timeline-line-style="timelineLineStyle"
|
||||||
@@ -278,7 +278,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
:section-action-disabled="sectionActionDisabled"
|
:section-action-disabled="sectionActionDisabled"
|
||||||
:section-action-style="sectionActionStyle"
|
:section-action-style="sectionActionStyle"
|
||||||
:nomination-phase="nominationPhase"
|
:nomination-phase="nominationPhase"
|
||||||
:review-phase="reviewPhase"
|
:preparation-phase="preparationPhase"
|
||||||
:on-section-action="onSectionAction"
|
:on-section-action="onSectionAction"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
|||||||
:winner-platform-key="winnerPlatformKey"
|
:winner-platform-key="winnerPlatformKey"
|
||||||
:winner-platform-label="winnerPlatformLabel"
|
:winner-platform-label="winnerPlatformLabel"
|
||||||
:privacy-modal-open="privacyModalOpen"
|
:privacy-modal-open="privacyModalOpen"
|
||||||
:privacy-content-blocks="privacyContentBlocks"
|
:privacy-content-html="privacyContentHtml"
|
||||||
:on-close-privacy="onClosePrivacy"
|
:on-close-privacy="onClosePrivacy"
|
||||||
:privacy-modal-stop="privacyModalStop"
|
:privacy-modal-stop="privacyModalStop"
|
||||||
:account-modal-open="accountModalOpen"
|
:account-modal-open="accountModalOpen"
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ defineProps<{
|
|||||||
winnerPlatformKey: (url: string) => string
|
winnerPlatformKey: (url: string) => string
|
||||||
winnerPlatformLabel: (url: string) => string
|
winnerPlatformLabel: (url: string) => string
|
||||||
privacyModalOpen: boolean
|
privacyModalOpen: boolean
|
||||||
privacyContentBlocks: string[]
|
privacyContentHtml: string
|
||||||
onClosePrivacy: () => void
|
onClosePrivacy: () => void
|
||||||
privacyModalStop: (event: Event) => void
|
privacyModalStop: (event: Event) => void
|
||||||
accountModalOpen: boolean
|
accountModalOpen: boolean
|
||||||
@@ -132,7 +132,7 @@ defineProps<{
|
|||||||
|
|
||||||
<HomeAccountAndPrivacyModals
|
<HomeAccountAndPrivacyModals
|
||||||
:privacy-modal-open="privacyModalOpen"
|
:privacy-modal-open="privacyModalOpen"
|
||||||
:privacy-content-blocks="privacyContentBlocks"
|
:privacy-content-html="privacyContentHtml"
|
||||||
:on-close-privacy="onClosePrivacy"
|
:on-close-privacy="onClosePrivacy"
|
||||||
:privacy-modal-stop="privacyModalStop"
|
:privacy-modal-stop="privacyModalStop"
|
||||||
:account-modal-open="accountModalOpen"
|
:account-modal-open="accountModalOpen"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
|||||||
sectionActionDisabled: boolean
|
sectionActionDisabled: boolean
|
||||||
sectionActionStyle: string
|
sectionActionStyle: string
|
||||||
nominationPhase: boolean
|
nominationPhase: boolean
|
||||||
reviewPhase: boolean
|
preparationPhase: boolean
|
||||||
onSectionAction: (event?: Event) => void
|
onSectionAction: (event?: Event) => void
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -72,7 +72,7 @@ const props = defineProps<{
|
|||||||
:style="props.sectionActionStyle"
|
:style="props.sectionActionStyle"
|
||||||
style-hover="transform:translateY(-2px);"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||||
|
|
||||||
interface HomeSocialLink {
|
interface HomeSocialLink {
|
||||||
label: string
|
label: string
|
||||||
platform: string
|
platform: string
|
||||||
@@ -7,8 +11,10 @@ interface HomeSocialLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FooterLink {
|
interface FooterLink {
|
||||||
|
key: string
|
||||||
label: string
|
label: string
|
||||||
url: string
|
url: string
|
||||||
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteContent {
|
interface SiteContent {
|
||||||
@@ -31,6 +37,25 @@ const props = defineProps<{
|
|||||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||||
platformKey: (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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -111,10 +136,83 @@ const props = defineProps<{
|
|||||||
VTuber Star Award 2026
|
VTuber Star Award 2026
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
|
<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>
|
<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>
|
||||||
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with ♡ & Chaos</div>
|
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with ♡ & Chaos</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</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;">
|
<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="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>
|
<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>
|
<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 Review & Auswertung bis ganz zum Schluss zur grossen Show.</p>
|
<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>
|
||||||
|
|
||||||
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
|
<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>
|
||||||
|
|
||||||
<div style="position:relative;z-index:1;text-align:center;">
|
<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">
|
<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>
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<template v-else>
|
<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>
|
<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>
|
</template>
|
||||||
</div>
|
</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="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="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>
|
<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;">Review & Auswertung</h3>
|
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Aufbereitung</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>
|
<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 prüft Fairness, Stimmen und Clips, wertet die Ergebnisse aus und bereitet die Show final vor.</p>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
disabled
|
||||||
aria-disabled="true"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,12 +131,12 @@
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
nominationPhase: boolean
|
nominationPhase: boolean
|
||||||
votingPhase: boolean
|
votingPhase: boolean
|
||||||
reviewPhase: boolean
|
preparationPhase: boolean
|
||||||
showPhase: boolean
|
showPhase: boolean
|
||||||
completedPhase: boolean
|
completedPhase: boolean
|
||||||
timelineLineStyle: string
|
timelineLineStyle: string
|
||||||
publicStreamUrl: string
|
publicStreamUrl: string
|
||||||
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
|
formatTimelineRange: (key: 'nomination' | 'voting' | 'preparation' | 'show') => string
|
||||||
openNominate: (event?: Event) => void
|
openNominate: (event?: Event) => void
|
||||||
openVote: (event?: Event) => void
|
openVote: (event?: Event) => void
|
||||||
onTimelineFinalAction: (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;"
|
: "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) {
|
function preparationButtonStyle(preparationPhase: boolean, showPhase: boolean, completedPhase: boolean) {
|
||||||
if (reviewPhase) {
|
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;"
|
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 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 type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
|
||||||
|
|
||||||
export interface HomeClipSubmitContext {
|
export interface HomeClipSubmitContext {
|
||||||
clipUrl: string
|
clipUrl: string
|
||||||
selectedNomineeIndex: number
|
selectedNomineeQuery: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeNominationSubmitContext {
|
export interface HomeNominationSubmitContext {
|
||||||
categoryIndex: number
|
categoryIndex: number
|
||||||
name: string
|
|
||||||
streamUrl: string
|
streamUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface HomeSelectionOption {
|
|||||||
export interface HomeArchiveYearItem {
|
export interface HomeArchiveYearItem {
|
||||||
year: number
|
year: number
|
||||||
label: string
|
label: string
|
||||||
|
winnerCount: number
|
||||||
winners: Array<unknown>
|
winners: Array<unknown>
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,30 @@ type AwardsStore = ReturnType<typeof useAwardsStore>
|
|||||||
|
|
||||||
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
|
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
|
||||||
const archiveYears = computed(() => {
|
const archiveYears = computed(() => {
|
||||||
const knownYears = new Set<number>(store.overview.winnersPreview.map((entry) => entry.year))
|
const knownYears = new Map<number, number>()
|
||||||
if (store.archive.items.length > 0) {
|
for (const entry of store.overview.archiveYears ?? []) {
|
||||||
knownYears.add(store.archive.year)
|
knownYears.set(entry.year, entry.winnerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...knownYears]
|
for (const entry of store.overview.winnersPreview) {
|
||||||
.sort((left, right) => right - left)
|
if (!knownYears.has(entry.year)) {
|
||||||
.map((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,
|
year,
|
||||||
label: String(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),
|
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
|
||||||
active: archiveYear.value === year,
|
active: archiveYear.value === year,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandin
|
|||||||
|
|
||||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||||
type AuthStore = ReturnType<typeof useAuthStore>
|
type AuthStore = ReturnType<typeof useAuthStore>
|
||||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
|
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
|
||||||
|
|
||||||
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
|
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
|
||||||
|
|
||||||
@@ -40,7 +40,9 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
|||||||
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
||||||
)
|
)
|
||||||
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
|
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
|
||||||
store.overview.winnersPreview.length > 0
|
(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 }))
|
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
|
||||||
: [{ year: store.overview.year - 1 }],
|
: [{ year: store.overview.year - 1 }],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function useHomeLandingState() {
|
|||||||
hostSocialLinks,
|
hostSocialLinks,
|
||||||
communitySocialLinks,
|
communitySocialLinks,
|
||||||
footerLinks,
|
footerLinks,
|
||||||
privacyContentBlocks,
|
privacyContentHtml,
|
||||||
platformKey,
|
platformKey,
|
||||||
isUploadedSocialIcon,
|
isUploadedSocialIcon,
|
||||||
socialSimpleIconPath,
|
socialSimpleIconPath,
|
||||||
@@ -101,7 +101,7 @@ export function useHomeLandingState() {
|
|||||||
const {
|
const {
|
||||||
nominationPhase,
|
nominationPhase,
|
||||||
votingPhase,
|
votingPhase,
|
||||||
reviewPhase,
|
preparationPhase,
|
||||||
showPhase,
|
showPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
showCountdown,
|
showCountdown,
|
||||||
@@ -257,13 +257,13 @@ export function useHomeLandingState() {
|
|||||||
communitySocialLinks,
|
communitySocialLinks,
|
||||||
footerLinks,
|
footerLinks,
|
||||||
faqItems,
|
faqItems,
|
||||||
privacyContentBlocks,
|
privacyContentHtml,
|
||||||
publicStreamUrl,
|
publicStreamUrl,
|
||||||
displayCategories,
|
displayCategories,
|
||||||
candidateCount,
|
candidateCount,
|
||||||
nominationPhase,
|
nominationPhase,
|
||||||
votingPhase,
|
votingPhase,
|
||||||
reviewPhase,
|
preparationPhase,
|
||||||
showPhase,
|
showPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
showCountdown,
|
showCountdown,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
|||||||
|
|
||||||
type AuthStore = ReturnType<typeof useAuthStore>
|
type AuthStore = ReturnType<typeof useAuthStore>
|
||||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
|
||||||
|
|
||||||
interface PhaseCountdownTarget {
|
interface PhaseCountdownTarget {
|
||||||
label: string
|
label: string
|
||||||
@@ -35,11 +35,11 @@ interface UseHomeLandingViewEffectsParams {
|
|||||||
archiveYear: Readonly<Ref<number>>
|
archiveYear: Readonly<Ref<number>>
|
||||||
nominationPhase: Readonly<Ref<boolean>>
|
nominationPhase: Readonly<Ref<boolean>>
|
||||||
votingPhase: Readonly<Ref<boolean>>
|
votingPhase: Readonly<Ref<boolean>>
|
||||||
reviewPhase: Readonly<Ref<boolean>>
|
preparationPhase: Readonly<Ref<boolean>>
|
||||||
completedPhase: Readonly<Ref<boolean>>
|
completedPhase: Readonly<Ref<boolean>>
|
||||||
initializeHomeInteractions: () => Promise<void>
|
initializeHomeInteractions: () => Promise<void>
|
||||||
submitNomination: (nominationContext: HomeNominationSubmitContext) => 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) {
|
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
|
||||||
@@ -56,7 +56,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
archiveYear,
|
archiveYear,
|
||||||
nominationPhase,
|
nominationPhase,
|
||||||
votingPhase,
|
votingPhase,
|
||||||
reviewPhase,
|
preparationPhase,
|
||||||
completedPhase,
|
completedPhase,
|
||||||
initializeHomeInteractions,
|
initializeHomeInteractions,
|
||||||
submitNomination,
|
submitNomination,
|
||||||
@@ -66,10 +66,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
const rootEl = ref<HTMLElement | null>(null)
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
const landingLoaderVisible = ref(true)
|
const landingLoaderVisible = ref(true)
|
||||||
const nominationCatEl = ref<HTMLSelectElement | null>(null)
|
const nominationCatEl = ref<HTMLSelectElement | null>(null)
|
||||||
const nominationNameEl = ref<HTMLInputElement | null>(null)
|
|
||||||
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
|
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
|
||||||
const clipUrlEl = 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 clipDescEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
const countdownRefs = {
|
const countdownRefs = {
|
||||||
labelEl: ref<HTMLElement | null>(null),
|
labelEl: ref<HTMLElement | null>(null),
|
||||||
@@ -93,7 +92,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
function handleClipSubmit() {
|
function handleClipSubmit() {
|
||||||
return submitClip({
|
return submitClip({
|
||||||
clipUrl: clipUrlEl.value?.value.trim() ?? '',
|
clipUrl: clipUrlEl.value?.value.trim() ?? '',
|
||||||
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
|
selectedNomineeQuery: clipNomSearchEl.value?.value.trim() ?? '',
|
||||||
description: clipDescEl.value?.value.trim() ?? '',
|
description: clipDescEl.value?.value.trim() ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
function handleNominationSubmit() {
|
function handleNominationSubmit() {
|
||||||
return submitNomination({
|
return submitNomination({
|
||||||
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
|
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
|
||||||
name: nominationNameEl.value?.value.trim() ?? '',
|
|
||||||
streamUrl: nominationStreamUrlEl.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.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
|
||||||
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
||||||
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
|
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
|
||||||
nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]')
|
|
||||||
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
|
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
|
||||||
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
|
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"]')
|
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +175,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
|||||||
function resolveSelectedPhaseKey(): HomeTimelineKey {
|
function resolveSelectedPhaseKey(): HomeTimelineKey {
|
||||||
if (nominationPhase.value) return 'nomination'
|
if (nominationPhase.value) return 'nomination'
|
||||||
if (votingPhase.value) return 'voting'
|
if (votingPhase.value) return 'voting'
|
||||||
if (reviewPhase.value) return 'review'
|
if (preparationPhase.value) return 'preparation'
|
||||||
if (completedPhase.value) return 'completed'
|
if (completedPhase.value) return 'completed'
|
||||||
return 'show'
|
return 'show'
|
||||||
}
|
}
|
||||||
@@ -297,7 +294,7 @@ function resolvePhaseCountdownTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
|
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
|
return phaseOrder
|
||||||
.map((key): TimelineScheduleItem | null => {
|
.map((key): TimelineScheduleItem | null => {
|
||||||
@@ -358,8 +355,8 @@ function phaseTitle(key: HomeTimelineKey) {
|
|||||||
? 'Nominierung'
|
? 'Nominierung'
|
||||||
: key === 'voting'
|
: key === 'voting'
|
||||||
? 'Voting'
|
? 'Voting'
|
||||||
: key === 'review'
|
: key === 'preparation'
|
||||||
? 'Review & Auswertung'
|
? 'Aufbereitung'
|
||||||
: 'Award Show'
|
: 'Award Show'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||