Refine admin workflows and team access
This commit is contained in:
@@ -17,6 +17,8 @@ public sealed record AdminTeamMemberDto(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
DateTimeOffset? LastLoginAt,
|
||||
DateTimeOffset? LastOnlineAt,
|
||||
bool IsOnline,
|
||||
DateTimeOffset? TwitchBoundAt,
|
||||
DateTimeOffset? PasswordResetAt);
|
||||
|
||||
|
||||
@@ -14,18 +14,10 @@ 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,
|
||||
|
||||
@@ -103,6 +103,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
|
||||
modelBuilder.Entity<VoteBallot>(entity =>
|
||||
{
|
||||
entity.HasIndex(item => new { item.SeasonId, item.SubmittedByTwitchId }).IsUnique();
|
||||
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
|
||||
entity.Property(item => item.Status).HasMaxLength(30);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class TeamAccountBootstrapper
|
||||
defaultLogin: "jayuhime",
|
||||
defaultDisplayName: "Jayuhime",
|
||||
role: AdminRoles.Owner,
|
||||
fallbackPassword: configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
|
||||
fallbackPassword: null);
|
||||
|
||||
var creatorSeed = BuildSeed(
|
||||
configuration,
|
||||
@@ -33,6 +33,11 @@ public static class TeamAccountBootstrapper
|
||||
await EnsureMemberAsync(db, seed);
|
||||
}
|
||||
|
||||
await DeactivateDemoBackedSeedAccountAsync(
|
||||
db,
|
||||
ownerSeed,
|
||||
configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -80,6 +85,18 @@ public static class TeamAccountBootstrapper
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (member.CreatedByTwitchId == SeedActor
|
||||
&& (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId)
|
||||
|| string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal))
|
||||
&& (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal)
|
||||
|| !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal)))
|
||||
{
|
||||
member.PasswordHash = seed.Credentials.Value.Hash;
|
||||
member.PasswordSalt = seed.Credentials.Value.Salt;
|
||||
member.MustChangePassword = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
@@ -87,6 +104,26 @@ public static class TeamAccountBootstrapper
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword)
|
||||
{
|
||||
if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
|
||||
if (member is null
|
||||
|| member.CreatedByTwitchId != SeedActor
|
||||
|| !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
member.IsActive = false;
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedByTwitchId = SeedActor;
|
||||
}
|
||||
|
||||
private static TeamAccountSeed BuildSeed(
|
||||
IConfiguration configuration,
|
||||
string sectionName,
|
||||
|
||||
@@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
|
||||
if (urlValidationError is not null)
|
||||
{
|
||||
return urlValidationError;
|
||||
}
|
||||
|
||||
settings.HostDisplayName = request.HostDisplayName.Trim();
|
||||
settings.HostTagline = request.HostTagline.Trim();
|
||||
settings.NewsletterUrl = request.NewsletterUrl.Trim();
|
||||
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
|
||||
settings.PrivacyEmail = request.PrivacyEmail.Trim();
|
||||
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
|
||||
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
|
||||
@@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints
|
||||
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
settings.ImprintUrl = request.ImprintUrl.Trim();
|
||||
settings.ImprintUrl = normalizedUrls.ImprintUrl;
|
||||
settings.ImprintContent = request.ImprintContent.Trim();
|
||||
settings.ContactUrl = request.ContactUrl.Trim();
|
||||
settings.ContactUrl = normalizedUrls.ContactUrl;
|
||||
settings.ContactContent = request.ContactContent.Trim();
|
||||
settings.SponsorsUrl = request.SponsorsUrl.Trim();
|
||||
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
|
||||
settings.SponsorsContent = request.SponsorsContent.Trim();
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
|
||||
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
|
||||
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
@@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints
|
||||
{
|
||||
settings.HostDisplayName,
|
||||
privacyChanged,
|
||||
socialLinkCount = request.SocialLinks?.Length ?? 0,
|
||||
socialLinkCount = socialLinks.Length,
|
||||
faqCount = request.Faq?.Length ?? 0,
|
||||
},
|
||||
RequestMetadataReader.Read(context));
|
||||
@@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints
|
||||
return Results.Ok(new { saved = true });
|
||||
}
|
||||
|
||||
private static IResult? NormalizeSiteSettingsUrls(
|
||||
UpdateSiteSettingsRequest request,
|
||||
out PublicSiteUrlSettings normalizedUrls,
|
||||
out PublicSocialLinkDto[] socialLinks)
|
||||
{
|
||||
normalizedUrls = new PublicSiteUrlSettings();
|
||||
socialLinks = [];
|
||||
|
||||
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|
||||
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
normalizedUrls = new PublicSiteUrlSettings
|
||||
{
|
||||
NewsletterUrl = newsletterUrl,
|
||||
ImprintUrl = imprintUrl,
|
||||
ContactUrl = contactUrl,
|
||||
SponsorsUrl = sponsorsUrl,
|
||||
};
|
||||
|
||||
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
||||
foreach (var social in request.SocialLinks ?? [])
|
||||
{
|
||||
if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage))
|
||||
{
|
||||
return Results.BadRequest(new { message = errorMessage });
|
||||
}
|
||||
|
||||
normalizedSocialLinks.Add(social with { Url = normalizedUrl });
|
||||
}
|
||||
|
||||
socialLinks = normalizedSocialLinks.ToArray();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
|
||||
{
|
||||
normalizedUrl = (value ?? string.Empty).Trim();
|
||||
errorMessage = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedUrl))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errorMessage = $"{fieldName} muss eine absolute http/https-URL sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUrl = uri.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class PublicSiteUrlSettings
|
||||
{
|
||||
public string NewsletterUrl { get; set; } = string.Empty;
|
||||
public string ImprintUrl { get; set; } = string.Empty;
|
||||
public string ContactUrl { get; set; } = string.Empty;
|
||||
public string SponsorsUrl { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
|
||||
{
|
||||
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Backend.Endpoints;
|
||||
public static class AdminTeamEndpoints
|
||||
{
|
||||
private const int MinPasswordLength = 10;
|
||||
private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
|
||||
private const string PersonalOwnerLogin = "jayuhime";
|
||||
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
|
||||
|
||||
@@ -79,11 +80,21 @@ public static class AdminTeamEndpoints
|
||||
.OrderByDescending(item => item.Role == AdminRoles.Owner)
|
||||
.ThenBy(item => item.Role)
|
||||
.ThenBy(item => item.DisplayName)
|
||||
.Select(item => ToMemberDto(item))
|
||||
.ToArrayAsync();
|
||||
var sessionKeys = members
|
||||
.SelectMany(MemberSessionKeys)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var sessions = sessionKeys.Length == 0
|
||||
? []
|
||||
: await db.UserSessions
|
||||
.AsNoTracking()
|
||||
.Where(item => sessionKeys.Contains(item.TwitchUserId))
|
||||
.ToArrayAsync();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
return Results.Ok(new AdminTeamResponse(
|
||||
members,
|
||||
members.Select(member => ToMemberDto(member, sessions, now)),
|
||||
await BuildRoleDtosAsync(db),
|
||||
PermissionCatalog));
|
||||
}
|
||||
@@ -322,10 +333,15 @@ public static class AdminTeamEndpoints
|
||||
IAdminAuditService adminAuditService)
|
||||
{
|
||||
var session = AdminEndpointConventions.CurrentSession(context);
|
||||
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
|
||||
var roleChanges = new List<object>();
|
||||
|
||||
foreach (var roleUpdate in request.Roles ?? [])
|
||||
{
|
||||
@@ -339,14 +355,18 @@ public static class AdminTeamEndpoints
|
||||
.Select(item => item.Trim())
|
||||
.Where(item => allowedPermissions.Contains(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(item => item)
|
||||
.ToArray();
|
||||
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
|
||||
|
||||
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
|
||||
}
|
||||
|
||||
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
|
||||
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
|
||||
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
|
||||
|
||||
if (!existingRows.TryGetValue(role, out var row))
|
||||
{
|
||||
row = new TeamRolePermission { Role = role };
|
||||
@@ -356,6 +376,18 @@ public static class AdminTeamEndpoints
|
||||
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
|
||||
row.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
row.UpdatedByTwitchId = session.TwitchUserId;
|
||||
|
||||
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
roleChanges.Add(new
|
||||
{
|
||||
role,
|
||||
before = beforePermissions,
|
||||
after = permissionKeys,
|
||||
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
adminAuditService.AddEntry(
|
||||
@@ -364,7 +396,7 @@ public static class AdminTeamEndpoints
|
||||
"team-role",
|
||||
"matrix",
|
||||
"Team-Rollenberechtigungen wurden gespeichert.",
|
||||
new { roleCount = request.Roles?.Length ?? 0 },
|
||||
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
|
||||
RequestMetadataReader.Read(context));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
@@ -384,18 +416,30 @@ public static class AdminTeamEndpoints
|
||||
? ReadPermissionKeys(json, role.PermissionKeys)
|
||||
: role.PermissionKeys;
|
||||
|
||||
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
|
||||
{
|
||||
permissions = AdminPermissionCatalog.AllPermissionKeys;
|
||||
}
|
||||
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
|
||||
|
||||
return role with { PermissionKeys = permissions };
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
|
||||
new(
|
||||
private static AdminTeamMemberDto ToMemberDto(
|
||||
TeamMember member,
|
||||
IReadOnlyCollection<UserSession>? sessions = null,
|
||||
DateTimeOffset? now = null)
|
||||
{
|
||||
var memberSessions = sessions?
|
||||
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
|
||||
.ToArray() ?? [];
|
||||
var lastSeenAt = memberSessions
|
||||
.Select(session => (DateTimeOffset?)session.LastSeenAt)
|
||||
.Max() ?? member.LastLoginAt;
|
||||
var isOnline = member.IsActive
|
||||
&& memberSessions.Any(session =>
|
||||
session.IsActive
|
||||
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
|
||||
|
||||
return new(
|
||||
member.Id,
|
||||
member.Login,
|
||||
member.DisplayName,
|
||||
@@ -407,8 +451,21 @@ public static class AdminTeamEndpoints
|
||||
member.CreatedAt,
|
||||
member.UpdatedAt,
|
||||
member.LastLoginAt,
|
||||
lastSeenAt,
|
||||
isOnline,
|
||||
member.TwitchBoundAt,
|
||||
member.PasswordResetAt);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
|
||||
{
|
||||
yield return $"team:{member.Login}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
|
||||
{
|
||||
yield return member.BoundTwitchUserId;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
|
||||
{
|
||||
|
||||
@@ -23,21 +23,11 @@ public static partial class AuthEndpoints
|
||||
.WithName("TeamLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/team-twitch-login", TeamTwitchLogin)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("TeamTwitchLogin")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/password/change", ChangePassword)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("ChangePassword")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/team/twitch-binding", BindTeamTwitch)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("BindTeamTwitch")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
|
||||
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
|
||||
.WithName("StartTwitchAuthorization")
|
||||
|
||||
@@ -47,38 +47,6 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> TeamTwitchLogin(
|
||||
HttpContext context,
|
||||
TeamTwitchLoginRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var member = await db.TeamMembers.FirstOrDefaultAsync(
|
||||
item => item.BoundTwitchUserId == twitchUserId,
|
||||
context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
member.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
var session = await userSessionService.CreateSessionAsync(
|
||||
twitchUserId,
|
||||
member.DisplayName,
|
||||
member.Role,
|
||||
RequestMetadataReader.Read(context),
|
||||
context.RequestAborted);
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(
|
||||
HttpContext context,
|
||||
ChangePasswordRequest request,
|
||||
@@ -127,64 +95,6 @@ public static partial class AuthEndpoints
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static async Task<IResult> BindTeamTwitch(
|
||||
HttpContext context,
|
||||
BindTeamTwitchRequest request,
|
||||
AwardsDbContext db,
|
||||
IUserSessionService userSessionService)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
|
||||
if (member is null || !member.IsActive)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
|
||||
}
|
||||
|
||||
if (member.MustChangePassword)
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
|
||||
}
|
||||
|
||||
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
|
||||
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
|
||||
if (string.IsNullOrWhiteSpace(twitchUserId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
|
||||
}
|
||||
|
||||
var alreadyBound = await db.TeamMembers.AnyAsync(
|
||||
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
|
||||
context.RequestAborted);
|
||||
if (alreadyBound)
|
||||
{
|
||||
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
|
||||
}
|
||||
|
||||
var previousTwitchUserId = member.BoundTwitchUserId;
|
||||
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
|
||||
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
|
||||
{
|
||||
oldSession.IsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
member.BoundTwitchUserId = twitchUserId;
|
||||
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
|
||||
member.TwitchBoundAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
member.UpdatedByTwitchId = session.TwitchUserId;
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
|
||||
}
|
||||
|
||||
private static string BuildTeamSessionId(string login) =>
|
||||
$"{TeamSessionPrefix}{login}";
|
||||
|
||||
@@ -215,7 +125,4 @@ public static partial class AuthEndpoints
|
||||
|
||||
private static string NormalizeTwitchUserId(string? value) =>
|
||||
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
|
||||
|
||||
private static string NormalizeTwitchDisplayName(string? value) =>
|
||||
(value ?? string.Empty).Trim();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
|
||||
@@ -72,29 +73,7 @@ public static partial class PublicEndpoints
|
||||
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var isResubmission = ballot is not null;
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
||||
|
||||
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
|
||||
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
|
||||
@@ -102,7 +81,21 @@ public static partial class PublicEndpoints
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
|
||||
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
ballot = await db.VoteBallots
|
||||
.Include(item => item.Entries)
|
||||
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
|
||||
isResubmission = true;
|
||||
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
|
||||
var ballotLink = new
|
||||
{
|
||||
label = "Voting-Analytics öffnen",
|
||||
@@ -142,4 +135,42 @@ public static partial class PublicEndpoints
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||
}
|
||||
|
||||
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
|
||||
AwardsDbContext db,
|
||||
VoteBallot? ballot,
|
||||
int seasonId,
|
||||
string submitterId,
|
||||
VoteEntryRequest[] entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
|
||||
return ballot;
|
||||
}
|
||||
|
||||
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
|
||||
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
|
||||
}
|
||||
|
||||
@@ -9,10 +9,25 @@ public static partial class PublicEndpoints
|
||||
{
|
||||
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
|
||||
{
|
||||
var season = await db.Seasons
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Year == year)
|
||||
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
|
||||
.FirstOrDefaultAsync();
|
||||
if (season is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
|
||||
{
|
||||
return Results.Ok(new WinnerArchiveResponse(year, []));
|
||||
}
|
||||
|
||||
var winnerRows = await db.Results
|
||||
.AsNoTracking()
|
||||
.Include(result => result.Candidate)
|
||||
.Where(result => result.Season.Year == year)
|
||||
.Where(result => result.SeasonId == season.Id)
|
||||
.OrderBy(result => result.CategoryName)
|
||||
.Select(result => new
|
||||
{
|
||||
@@ -34,4 +49,10 @@ public static partial class PublicEndpoints
|
||||
|
||||
return Results.Ok(new WinnerArchiveResponse(year, items));
|
||||
}
|
||||
|
||||
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
|
||||
{
|
||||
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
|
||||
return phaseKey is "completed";
|
||||
}
|
||||
}
|
||||
|
||||
+1471
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVoteBallotSubmitterUniqueIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VoteBallots_SeasonId",
|
||||
table: "VoteBallots");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TwitchAuthManagedByDatabase",
|
||||
table: "SiteSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchClientId",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchClientSecret",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(180)",
|
||||
maxLength: 180,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchRedirectUri",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(400)",
|
||||
maxLength: 400,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TwitchScope",
|
||||
table: "SiteSettings",
|
||||
type: "character varying(300)",
|
||||
maxLength: 300,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "SiteSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
columns: new[] { "TwitchAuthManagedByDatabase", "TwitchClientId", "TwitchClientSecret", "TwitchRedirectUri", "TwitchScope" },
|
||||
values: new object[] { false, "", "", "", "" });
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM "VoteEntries"
|
||||
WHERE "BallotId" IN (
|
||||
SELECT "Id"
|
||||
FROM (
|
||||
SELECT
|
||||
"Id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "SeasonId", "SubmittedByTwitchId"
|
||||
ORDER BY "SubmittedAt" DESC, "Id" DESC
|
||||
) AS duplicate_rank
|
||||
FROM "VoteBallots"
|
||||
) ranked_ballots
|
||||
WHERE duplicate_rank > 1
|
||||
);
|
||||
|
||||
DELETE FROM "VoteBallots"
|
||||
WHERE "Id" IN (
|
||||
SELECT "Id"
|
||||
FROM (
|
||||
SELECT
|
||||
"Id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "SeasonId", "SubmittedByTwitchId"
|
||||
ORDER BY "SubmittedAt" DESC, "Id" DESC
|
||||
) AS duplicate_rank
|
||||
FROM "VoteBallots"
|
||||
) ranked_ballots
|
||||
WHERE duplicate_rank > 1
|
||||
);
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
|
||||
table: "VoteBallots",
|
||||
columns: new[] { "SeasonId", "SubmittedByTwitchId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
|
||||
table: "VoteBallots");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchAuthManagedByDatabase",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchClientId",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchClientSecret",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchRedirectUri",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TwitchScope",
|
||||
table: "SiteSettings");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VoteBallots_SeasonId",
|
||||
table: "VoteBallots",
|
||||
column: "SeasonId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -966,6 +966,29 @@ namespace Backend.Migrations
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("character varying(400)");
|
||||
|
||||
b.Property<bool>("TwitchAuthManagedByDatabase")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TwitchClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("TwitchClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(180)
|
||||
.HasColumnType("character varying(180)");
|
||||
|
||||
b.Property<string>("TwitchRedirectUri")
|
||||
.IsRequired()
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("character varying(400)");
|
||||
|
||||
b.Property<string>("TwitchScope")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteSettings");
|
||||
@@ -999,7 +1022,12 @@ namespace Backend.Migrations
|
||||
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
|
||||
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
|
||||
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
|
||||
SponsorsUrl = "https://vtuber-star-awards.de/partner"
|
||||
SponsorsUrl = "https://vtuber-star-awards.de/partner",
|
||||
TwitchAuthManagedByDatabase = false,
|
||||
TwitchClientId = "",
|
||||
TwitchClientSecret = "",
|
||||
TwitchRedirectUri = "",
|
||||
TwitchScope = ""
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1011,14 +1039,6 @@ namespace Backend.Migrations
|
||||
|
||||
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)");
|
||||
@@ -1027,6 +1047,14 @@ namespace Backend.Migrations
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedByTwitchId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
@@ -1076,10 +1104,10 @@ namespace Backend.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Login")
|
||||
b.HasIndex("BoundTwitchUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("BoundTwitchUserId")
|
||||
b.HasIndex("Login")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TeamMembers");
|
||||
@@ -1197,7 +1225,8 @@ namespace Backend.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeasonId");
|
||||
b.HasIndex("SeasonId", "SubmittedByTwitchId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("VoteBallots");
|
||||
|
||||
|
||||
@@ -105,6 +105,13 @@ VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
|
||||
|
||||
The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`.
|
||||
|
||||
Team owner bootstrap credentials are separate from demo login credentials:
|
||||
|
||||
```text
|
||||
VTSA_TEAM_OWNER_PASSWORD=<set-secure-owner-password>
|
||||
VTSA_TEAM_CREATOR_PASSWORD=<set-secure-creator-password>
|
||||
```
|
||||
|
||||
Frontend app-wide demo gate:
|
||||
|
||||
```text
|
||||
|
||||
@@ -58,7 +58,7 @@ public static class AdminPermissionCatalog
|
||||
{
|
||||
AdminRoles.Owner => AllPermissionKeys,
|
||||
AdminRoles.Creator => AllPermissionKeys,
|
||||
AdminRoles.Admin => AllPermissionKeys.Where(key => key is not Settings).ToArray(),
|
||||
AdminRoles.Admin => AllPermissionKeys,
|
||||
AdminRoles.Member => [Dashboard, Nominations, Categories, Candidates, Clips, Content],
|
||||
AdminRoles.Reviewer => [Dashboard, Nominations, Clips, Risk, Audit],
|
||||
AdminRoles.OrganizationTeam => [Dashboard, Content, Analytics, Winners, Settings],
|
||||
@@ -67,6 +67,30 @@ public static class AdminPermissionCatalog
|
||||
};
|
||||
}
|
||||
|
||||
public static string[] NormalizePermissionKeys(string? role, IEnumerable<string> permissionKeys)
|
||||
{
|
||||
var normalizedRole = AdminRoles.Normalize(role);
|
||||
var permissions = permissionKeys
|
||||
.Where(IsKnownPermission)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalizedRole is AdminRoles.Owner or AdminRoles.Creator)
|
||||
{
|
||||
return AllPermissionKeys;
|
||||
}
|
||||
|
||||
if (DefaultPermissionKeys(normalizedRole).Contains(Settings, StringComparer.OrdinalIgnoreCase)
|
||||
&& !permissions.Contains(Settings, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
permissions.Add(Settings);
|
||||
}
|
||||
|
||||
return permissions
|
||||
.OrderBy(item => item)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static async Task<string[]> GetPermissionKeysAsync(
|
||||
AwardsDbContext db,
|
||||
string? role,
|
||||
@@ -92,11 +116,7 @@ public static class AdminPermissionCatalog
|
||||
|
||||
try
|
||||
{
|
||||
return (JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback)
|
||||
.Where(IsKnownPermission)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(item => item)
|
||||
.ToArray();
|
||||
return NormalizePermissionKeys(normalizedRole, JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Backend.Services;
|
||||
|
||||
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
|
||||
{
|
||||
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
|
||||
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = ReadBearerToken(context);
|
||||
@@ -23,7 +26,15 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
return null;
|
||||
}
|
||||
|
||||
session.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (IsExpired(session, now))
|
||||
{
|
||||
session.IsActive = false;
|
||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.LastSeenAt = now;
|
||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||
context.SetCurrentSession(session);
|
||||
return session;
|
||||
@@ -83,6 +94,10 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
|
||||
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
||||
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
|
||||
|
||||
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user