diff --git a/Backend/Data/TeamAccountBootstrapper.cs b/Backend/Data/TeamAccountBootstrapper.cs new file mode 100644 index 0000000..1ecdc4f --- /dev/null +++ b/Backend/Data/TeamAccountBootstrapper.cs @@ -0,0 +1,152 @@ +using Backend.Domain; +using Backend.Security; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Data; + +public static class TeamAccountBootstrapper +{ + private const string SeedActor = "system:team-seed"; + + public static async Task EnsureAsync(AwardsDbContext db, IConfiguration configuration) + { + var ownerSeed = BuildSeed( + configuration, + sectionName: "Owner", + environmentPrefix: "VTSA_TEAM_OWNER", + defaultLogin: "jayuhime", + defaultDisplayName: "Jayuhime", + role: AdminRoles.Owner, + fallbackPassword: configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]); + + var creatorSeed = BuildSeed( + configuration, + sectionName: "Creator", + environmentPrefix: "VTSA_TEAM_CREATOR", + defaultLogin: "sleepy_bao", + defaultDisplayName: "sleepy_bao", + role: AdminRoles.Creator, + fallbackPassword: null); + + foreach (var seed in new[] { ownerSeed, creatorSeed }) + { + await EnsureMemberAsync(db, seed); + } + + await db.SaveChangesAsync(); + } + + private static async Task EnsureMemberAsync(AwardsDbContext db, TeamAccountSeed seed) + { + if (seed.Credentials is null) + { + return; + } + + var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login); + if (member is null) + { + db.TeamMembers.Add(new TeamMember + { + Login = seed.Login, + DisplayName = seed.DisplayName, + Role = seed.Role, + PasswordHash = seed.Credentials.Value.Hash, + PasswordSalt = seed.Credentials.Value.Salt, + MustChangePassword = false, + IsActive = true, + CreatedByTwitchId = SeedActor, + CreatedAt = DateTimeOffset.UtcNow, + }); + return; + } + + var changed = false; + if (!string.Equals(member.DisplayName, seed.DisplayName, StringComparison.Ordinal)) + { + member.DisplayName = seed.DisplayName; + changed = true; + } + + if (!string.Equals(member.Role, seed.Role, StringComparison.Ordinal)) + { + member.Role = seed.Role; + changed = true; + } + + if (!member.IsActive) + { + member.IsActive = true; + changed = true; + } + + if (changed) + { + member.UpdatedAt = DateTimeOffset.UtcNow; + member.UpdatedByTwitchId = SeedActor; + } + } + + private static TeamAccountSeed BuildSeed( + IConfiguration configuration, + string sectionName, + string environmentPrefix, + string defaultLogin, + string defaultDisplayName, + string role, + string? fallbackPassword) + { + var login = ReadSetting(configuration, sectionName, environmentPrefix, "Login") ?? defaultLogin; + var displayName = ReadSetting(configuration, sectionName, environmentPrefix, "DisplayName") ?? defaultDisplayName; + + return new TeamAccountSeed( + NormalizeLogin(login), + displayName.Trim(), + role, + ResolveCredentials(configuration, sectionName, environmentPrefix, fallbackPassword)); + } + + private static TeamSeedCredentials? ResolveCredentials( + IConfiguration configuration, + string sectionName, + string environmentPrefix, + string? fallbackPassword) + { + var configuredHash = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordHash"); + var configuredSalt = ReadSetting(configuration, sectionName, environmentPrefix, "PasswordSalt"); + if (!string.IsNullOrWhiteSpace(configuredHash) && !string.IsNullOrWhiteSpace(configuredSalt)) + { + return new TeamSeedCredentials(configuredHash.Trim(), configuredSalt.Trim()); + } + + var password = ReadSetting(configuration, sectionName, environmentPrefix, "Password") ?? fallbackPassword; + if (string.IsNullOrWhiteSpace(password)) + { + return null; + } + + var credentials = DemoCredentialHasher.HashPassword(password); + return new TeamSeedCredentials(credentials.Hash, credentials.Salt); + } + + private static string? ReadSetting(IConfiguration configuration, string sectionName, string environmentPrefix, string key) + { + var raw = configuration[$"{environmentPrefix}_{ToEnvironmentKey(key)}"] + ?? configuration[$"TeamSeed:{sectionName}:{key}"]; + + return string.IsNullOrWhiteSpace(raw) ? null : raw; + } + + private static string ToEnvironmentKey(string key) + { + return string.Concat(key.Select((character, index) => + index > 0 && char.IsUpper(character) ? $"_{character}" : character.ToString())).ToUpperInvariant(); + } + + private static string NormalizeLogin(string value) => + value.Trim().TrimStart('@').ToLowerInvariant(); + + private sealed record TeamAccountSeed(string Login, string DisplayName, string Role, TeamSeedCredentials? Credentials); + + private readonly record struct TeamSeedCredentials(string Hash, string Salt); +} diff --git a/Backend/Extensions/WebApplicationExtensions.cs b/Backend/Extensions/WebApplicationExtensions.cs index 55c2605..acccb45 100644 --- a/Backend/Extensions/WebApplicationExtensions.cs +++ b/Backend/Extensions/WebApplicationExtensions.cs @@ -49,6 +49,7 @@ public static class WebApplicationExtensions await SessionBootstrapper.EnsureAsync(db); await OperationalTablesBootstrapper.EnsureAsync(db); + await TeamAccountBootstrapper.EnsureAsync(db, app.Configuration); if (ShouldSeedPresentationData(app)) { await SeedDataBootstrapper.EnsureAsync(db);