mirror of
https://github.com/Zheneq/EvoS
synced 2026-08-23 18:23:04 -04:00
Register via bot
This commit is contained in:
parent
aebaa3ea39
commit
e9a77b3878
18 changed files with 803 additions and 43 deletions
|
|
@ -8,16 +8,27 @@ namespace EvoS.Framework.DataAccess.Daos
|
|||
public interface RegistrationCodeDao
|
||||
{
|
||||
public const int LIMIT = 25;
|
||||
|
||||
|
||||
public RegistrationCodeEntry Find(string code);
|
||||
public List<RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime);
|
||||
public List<RegistrationCodeEntry> FindAll(int limit, int offset);
|
||||
public List<RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime);
|
||||
public List<RegistrationCodeEntry> FindAllIssued(int limit, int offset);
|
||||
public List<RegistrationCodeEntry> FindByState(RegistrationState state, int limit);
|
||||
public RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId);
|
||||
public void Save(RegistrationCodeEntry entry);
|
||||
|
||||
// Issued = 0 so legacy rows and manually issued codes (with no State field) are treated as issued.
|
||||
public enum RegistrationState
|
||||
{
|
||||
Issued = 0,
|
||||
Requested = 1,
|
||||
Declined = 2
|
||||
}
|
||||
|
||||
public class RegistrationCodeEntry
|
||||
{
|
||||
[BsonId]
|
||||
public string Code;
|
||||
public RegistrationState State;
|
||||
public long IssuedBy;
|
||||
public string IssuedTo;
|
||||
public DateTime IssuedAt;
|
||||
|
|
@ -25,6 +36,15 @@ namespace EvoS.Framework.DataAccess.Daos
|
|||
public DateTime UsedAt;
|
||||
public long UsedBy;
|
||||
|
||||
public ulong DiscordUserId;
|
||||
public string DiscordUserName;
|
||||
public string DiscordDisplayName;
|
||||
public string DiscordAvatarUrl;
|
||||
public DateTime DiscordCreatedAt;
|
||||
public DateTime? DiscordJoinedAt;
|
||||
public string DeclineReason;
|
||||
public DateTime RequestedAt;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsValid => !IsUsed && !HasExpired;
|
||||
|
||||
|
|
@ -39,14 +59,23 @@ namespace EvoS.Framework.DataAccess.Daos
|
|||
return new RegistrationCodeEntry
|
||||
{
|
||||
Code = Code,
|
||||
State = State,
|
||||
IssuedBy = IssuedBy,
|
||||
IssuedTo = IssuedTo,
|
||||
IssuedAt = IssuedAt,
|
||||
ExpiresAt = ExpiresAt,
|
||||
UsedAt = DateTime.UtcNow,
|
||||
UsedBy = accountId
|
||||
UsedBy = accountId,
|
||||
DiscordUserId = DiscordUserId,
|
||||
DiscordUserName = DiscordUserName,
|
||||
DiscordDisplayName = DiscordDisplayName,
|
||||
DiscordAvatarUrl = DiscordAvatarUrl,
|
||||
DiscordCreatedAt = DiscordCreatedAt,
|
||||
DiscordJoinedAt = DiscordJoinedAt,
|
||||
DeclineReason = DeclineReason,
|
||||
RequestedAt = RequestedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,40 +38,81 @@ namespace EvoS.Framework.DataAccess.Daos
|
|||
return nonCachedEntry;
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
|
||||
private static bool IsIssued(RegistrationCodeDao.RegistrationCodeEntry entry) =>
|
||||
entry.State != RegistrationCodeDao.RegistrationState.Requested
|
||||
&& entry.State != RegistrationCodeDao.RegistrationState.Declined;
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
|
||||
{
|
||||
if (dao is RegistrationCodeMockDao)
|
||||
{
|
||||
return cache
|
||||
.Select(x => x.Value)
|
||||
.Where(x => x.IssuedAt < dateTime)
|
||||
.Where(x => IsIssued(x) && x.IssuedAt < dateTime)
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindBefore(limit, dateTime);
|
||||
|
||||
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindIssuedBefore(limit, dateTime);
|
||||
daoEntries.ForEach(Cache);
|
||||
return daoEntries;
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
|
||||
{
|
||||
if (dao is RegistrationCodeMockDao)
|
||||
{
|
||||
return cache
|
||||
.Select(x => x.Value)
|
||||
.Where(IsIssued)
|
||||
.OrderByDescending(x => x.IssuedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindAll(limit, offset);
|
||||
|
||||
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindAllIssued(limit, offset);
|
||||
daoEntries.ForEach(Cache);
|
||||
return daoEntries;
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
|
||||
{
|
||||
if (dao is RegistrationCodeMockDao)
|
||||
{
|
||||
return cache
|
||||
.Select(x => x.Value)
|
||||
.Where(x => x.State == state)
|
||||
.OrderByDescending(x => x.RequestedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
List<RegistrationCodeDao.RegistrationCodeEntry> daoEntries = dao.FindByState(state, limit);
|
||||
daoEntries.ForEach(Cache);
|
||||
return daoEntries;
|
||||
}
|
||||
|
||||
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
|
||||
{
|
||||
if (dao is RegistrationCodeMockDao)
|
||||
{
|
||||
return cache
|
||||
.Select(x => x.Value)
|
||||
.Where(x => x.DiscordUserId == discordUserId)
|
||||
.OrderByDescending(x => x.RequestedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
RegistrationCodeDao.RegistrationCodeEntry entry = dao.FindLatestByDiscordUser(discordUserId);
|
||||
if (entry != null)
|
||||
{
|
||||
Cache(entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
|
||||
{
|
||||
dao.Save(entry);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ namespace EvoS.DirectoryServer.Account
|
|||
{
|
||||
private static readonly ILog log = LogManager.GetLogger(typeof(LoginManager));
|
||||
private static readonly HashAlgorithm algorithm = SHA256.Create();
|
||||
private static readonly Regex usernameRegex = new Regex(@"^[A-Za-z][A-Za-z_\-0-9]{3,}$");
|
||||
private static readonly Regex usernameRegex = new Regex(@"^[A-Za-z][A-Za-z_\-0-9]{3,23}$");
|
||||
private static readonly Regex bannedUsernameRegex = new Regex(@"^(?:(?:changeMeToYour)?user(?:name)?|admin|draft|gaia|maps)$", RegexOptions.IgnoreCase);
|
||||
private static readonly Regex bannedPasswordRegex = new Regex(@"^(?:(?:changeMeToYour)?password)$", RegexOptions.IgnoreCase);
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ namespace EvoS.DirectoryServer.Account
|
|||
public const string UserDoesNotExist = "User does not exist";
|
||||
public const string InvalidUsername = "Invalid username. " +
|
||||
"Please use only latin characters, numbers, underscore and dash, and start with a letter. " +
|
||||
"4 symbols or more.";
|
||||
"4 to 24 symbols.";
|
||||
public const string CannotUseThisUsername = "You cannot use this username. Please choose another.";
|
||||
public const string CannotUseThisPassword = "You cannot use this password. Please choose another.";
|
||||
public const string FailedToCreateAnAccount = "Failed to crate an account";
|
||||
|
|
|
|||
|
|
@ -11,16 +11,26 @@ namespace EvoS.Framework.DataAccess.Mock
|
|||
return null;
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
|
||||
{
|
||||
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
|
||||
{
|
||||
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
|
||||
{
|
||||
return new List<RegistrationCodeDao.RegistrationCodeEntry>();
|
||||
}
|
||||
|
||||
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
|
||||
{
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,39 +8,63 @@ namespace EvoS.Framework.DataAccess.Mongo
|
|||
public class RegistrationCodeMongoDao : MongoDao<string, RegistrationCodeDao.RegistrationCodeEntry>, RegistrationCodeDao
|
||||
{
|
||||
public RegistrationCodeMongoDao() : base(
|
||||
"registration_codes",
|
||||
"registration_codes",
|
||||
new CreateIndexModel<RegistrationCodeDao.RegistrationCodeEntry>(Builders<RegistrationCodeDao.RegistrationCodeEntry>.IndexKeys
|
||||
.Descending(entry => entry.IssuedAt)))
|
||||
.Descending(entry => entry.IssuedAt)),
|
||||
new CreateIndexModel<RegistrationCodeDao.RegistrationCodeEntry>(Builders<RegistrationCodeDao.RegistrationCodeEntry>.IndexKeys
|
||||
.Ascending(entry => entry.DiscordUserId)))
|
||||
{
|
||||
}
|
||||
|
||||
private FilterDefinition<RegistrationCodeDao.RegistrationCodeEntry> IssuedOnly =>
|
||||
f.And(
|
||||
f.Ne("State", RegistrationCodeDao.RegistrationState.Requested),
|
||||
f.Ne("State", RegistrationCodeDao.RegistrationState.Declined));
|
||||
|
||||
public RegistrationCodeDao.RegistrationCodeEntry Find(string code)
|
||||
{
|
||||
return c.Find(f.Eq("Code", code)).FirstOrDefault();
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindBefore(int limit, DateTime dateTime)
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindIssuedBefore(int limit, DateTime dateTime)
|
||||
{
|
||||
return c
|
||||
.Find(f.Lt("IssuedAt", dateTime))
|
||||
.Find(f.And(IssuedOnly, f.Lt("IssuedAt", dateTime)))
|
||||
.Sort(s.Descending("IssuedAt"))
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAll(int limit, int offset)
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindAllIssued(int limit, int offset)
|
||||
{
|
||||
return c
|
||||
.Find(f.Empty)
|
||||
.Find(IssuedOnly)
|
||||
.Sort(s.Descending("IssuedAt"))
|
||||
.Skip(offset)
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<RegistrationCodeDao.RegistrationCodeEntry> FindByState(RegistrationCodeDao.RegistrationState state, int limit)
|
||||
{
|
||||
return c
|
||||
.Find(f.Eq("State", state))
|
||||
.Sort(s.Descending("RequestedAt"))
|
||||
.Limit(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public RegistrationCodeDao.RegistrationCodeEntry FindLatestByDiscordUser(ulong discordUserId)
|
||||
{
|
||||
return c
|
||||
.Find(f.Eq("DiscordUserId", discordUserId))
|
||||
.Sort(s.Descending("RequestedAt"))
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public void Save(RegistrationCodeDao.RegistrationCodeEntry entry)
|
||||
{
|
||||
insert(entry.Code, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ public class AdminApiServer : ApiServer
|
|||
app.MapPost("/api/admin/player/registrationCode", AdminController.IssueRegistrationCode).RequireAuthorization("api_admin");
|
||||
app.MapPost("/api/admin/player/mapPickBan", AdminController.StartMapPickBan).RequireAuthorization("api_admin");
|
||||
app.MapGet("/api/admin/player/registrationCode", AdminController.GetRegistrationCodes).RequireAuthorization("api_admin");
|
||||
app.MapGet("/api/admin/player/usernameRequests", AdminController.GetUsernameRequests).RequireAuthorization("api_admin");
|
||||
app.MapPost("/api/admin/player/usernameRequest/confirm", AdminController.ConfirmUsernameRequest).RequireAuthorization("api_admin");
|
||||
app.MapPost("/api/admin/player/usernameRequest/decline", AdminController.DeclineUsernameRequest).RequireAuthorization("api_admin");
|
||||
app.MapGet("/api/admin/player/matches", MatchController.GetMatchHistory).RequireAuthorization("api_admin");
|
||||
app.MapGet("/api/admin/moderation/chatHistory", ModerationController.GetChatHistory).RequireAuthorization("api_admin");
|
||||
app.MapGet("/api/admin/moderation/sentFeedback", ModerationController.GetSentFeedback).RequireAuthorization("api_admin");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using CentralServer.LobbyServer;
|
|||
using CentralServer.LobbyServer.Chat;
|
||||
using CentralServer.LobbyServer.Config;
|
||||
using CentralServer.LobbyServer.CustomGames;
|
||||
using CentralServer.LobbyServer.Discord;
|
||||
using CentralServer.LobbyServer.Matchmaking;
|
||||
using CentralServer.LobbyServer.Session;
|
||||
using CentralServer.LobbyServer.Utils;
|
||||
|
|
@ -487,6 +488,10 @@ namespace CentralServer.ApiServer
|
|||
public DateTime IssuedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime UsedAt { get; set; }
|
||||
public string DiscordUserId { get; set; }
|
||||
public string DiscordUserName { get; set; }
|
||||
public string DiscordDisplayName { get; set; }
|
||||
public string DiscordAvatarUrl { get; set; }
|
||||
|
||||
public static RegistrationCodeEntryModel Of(RegistrationCodeDao.RegistrationCodeEntry e)
|
||||
{
|
||||
|
|
@ -500,6 +505,10 @@ namespace CentralServer.ApiServer
|
|||
IssuedAt = e.IssuedAt,
|
||||
ExpiresAt = e.ExpiresAt,
|
||||
UsedAt = e.UsedAt,
|
||||
DiscordUserId = e.DiscordUserId == 0 ? null : e.DiscordUserId.ToString(),
|
||||
DiscordUserName = e.DiscordUserName,
|
||||
DiscordDisplayName = e.DiscordDisplayName,
|
||||
DiscordAvatarUrl = e.DiscordAvatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -523,13 +532,128 @@ namespace CentralServer.ApiServer
|
|||
|
||||
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
|
||||
List<RegistrationCodeEntryModel> entries = (before > 0
|
||||
? dao.FindBefore(limit, DateTimeOffset.FromUnixTimeSeconds(before).UtcDateTime)
|
||||
: dao.FindAll(limit, offset))
|
||||
? dao.FindIssuedBefore(limit, DateTimeOffset.FromUnixTimeSeconds(before).UtcDateTime)
|
||||
: dao.FindAllIssued(limit, offset))
|
||||
.Select(RegistrationCodeEntryModel.Of)
|
||||
.ToList();
|
||||
return Results.Ok(new RegistrationCodesResponseModel { entries = entries });
|
||||
}
|
||||
|
||||
public class UsernameRequestEntryModel
|
||||
{
|
||||
public string Code { get; set; }
|
||||
public string RequestedUsername { get; set; }
|
||||
public string DiscordUserId { get; set; }
|
||||
public string DiscordUserName { get; set; }
|
||||
public string DiscordDisplayName { get; set; }
|
||||
public string DiscordAvatarUrl { get; set; }
|
||||
public DateTime DiscordCreatedAt { get; set; }
|
||||
public DateTime? DiscordJoinedAt { get; set; }
|
||||
public DateTime RequestedAt { get; set; }
|
||||
|
||||
public static UsernameRequestEntryModel Of(RegistrationCodeDao.RegistrationCodeEntry e)
|
||||
{
|
||||
return new UsernameRequestEntryModel
|
||||
{
|
||||
Code = e.Code,
|
||||
RequestedUsername = e.IssuedTo,
|
||||
DiscordUserId = e.DiscordUserId.ToString(),
|
||||
DiscordUserName = e.DiscordUserName,
|
||||
DiscordDisplayName = e.DiscordDisplayName,
|
||||
DiscordAvatarUrl = e.DiscordAvatarUrl,
|
||||
DiscordCreatedAt = e.DiscordCreatedAt,
|
||||
DiscordJoinedAt = e.DiscordJoinedAt,
|
||||
RequestedAt = e.RequestedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class UsernameRequestsResponseModel
|
||||
{
|
||||
public List<UsernameRequestEntryModel> entries { get; set; }
|
||||
}
|
||||
|
||||
public class ConfirmUsernameRequestModel
|
||||
{
|
||||
public string Code { get; set; }
|
||||
}
|
||||
|
||||
public class DeclineUsernameRequestModel
|
||||
{
|
||||
public string Code { get; set; }
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
|
||||
public static IResult GetUsernameRequests(ClaimsPrincipal user)
|
||||
{
|
||||
if (!ValidateAdmin(user, out IResult error, out _, out _))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
List<UsernameRequestEntryModel> entries = DB.Get().RegistrationCodeDao
|
||||
.FindByState(RegistrationCodeDao.RegistrationState.Requested, RegistrationCodeDao.LIMIT)
|
||||
.Select(UsernameRequestEntryModel.Of)
|
||||
.ToList();
|
||||
return Results.Ok(new UsernameRequestsResponseModel { entries = entries });
|
||||
}
|
||||
|
||||
public static IResult ConfirmUsernameRequest([FromBody] ConfirmUsernameRequestModel data, ClaimsPrincipal user)
|
||||
{
|
||||
if (!ValidateAdmin(user, out IResult error, out long adminAccountId, out string adminHandle))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
|
||||
RegistrationCodeDao.RegistrationCodeEntry entry = dao.Find(data.Code);
|
||||
if (entry is null || entry.State != RegistrationCodeDao.RegistrationState.Requested)
|
||||
{
|
||||
return Results.NotFound(new ApiServer.ErrorResponseModel { message = "Request not found" });
|
||||
}
|
||||
|
||||
if (DB.Get().LoginDao.Find(entry.IssuedTo) is not null)
|
||||
{
|
||||
return Results.Conflict(new ApiServer.ErrorResponseModel { message = "Username already in use" });
|
||||
}
|
||||
|
||||
log.Info($"API CONFIRM USERNAME REQUEST by {adminHandle} ({adminAccountId}): {entry.IssuedTo}");
|
||||
entry.State = RegistrationCodeDao.RegistrationState.Issued;
|
||||
entry.IssuedBy = adminAccountId;
|
||||
entry.IssuedAt = DateTime.UtcNow;
|
||||
entry.ExpiresAt = EvosConfiguration.GetRegistrationCodeLifetime().Ticks > 0
|
||||
? DateTime.UtcNow.Add(EvosConfiguration.GetRegistrationCodeLifetime())
|
||||
: DateTime.MaxValue;
|
||||
dao.Save(entry);
|
||||
|
||||
DiscordManager.Get().Bot?.PingUsernameRequestApproved(entry.DiscordUserId, entry.IssuedTo);
|
||||
return Results.Ok();
|
||||
}
|
||||
|
||||
public static IResult DeclineUsernameRequest([FromBody] DeclineUsernameRequestModel data, ClaimsPrincipal user)
|
||||
{
|
||||
if (!ValidateAdmin(user, out IResult error, out long adminAccountId, out string adminHandle))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
|
||||
RegistrationCodeDao.RegistrationCodeEntry entry = dao.Find(data.Code);
|
||||
if (entry is null || entry.State != RegistrationCodeDao.RegistrationState.Requested)
|
||||
{
|
||||
return Results.NotFound(new ApiServer.ErrorResponseModel { message = "Request not found" });
|
||||
}
|
||||
|
||||
log.Info($"API DECLINE USERNAME REQUEST by {adminHandle} ({adminAccountId}): {entry.IssuedTo}");
|
||||
entry.State = RegistrationCodeDao.RegistrationState.Declined;
|
||||
entry.IssuedBy = adminAccountId;
|
||||
entry.DeclineReason = data.Reason;
|
||||
dao.Save(entry);
|
||||
|
||||
DiscordManager.Get().Bot?.PingUsernameRequestDeclined(entry.DiscordUserId, data.Reason);
|
||||
return Results.Ok();
|
||||
}
|
||||
|
||||
public class MapPickBanModel
|
||||
{
|
||||
public long captainAAccountId { get; set; }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@
|
|||
<None Update="Config\lobby.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Config\discordBot.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Config\storeSettings.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
|
|||
4
LobbyServer2/Config/discordBot.yaml
Normal file
4
LobbyServer2/Config/discordBot.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Enabled: false
|
||||
BotToken: "" # Enter the bot's token, leave empty to disable
|
||||
BotChannelId: 0 # Channel id for 2-way chat relay (usually the LobbyChannel id)
|
||||
RequestChannelId: 0 # Channel id where players request usernames and get pinged
|
||||
|
|
@ -10,8 +10,6 @@ GroupConfig:
|
|||
CanInviteActiveOpponents: false
|
||||
Discord:
|
||||
Enabled: false
|
||||
BotToken: "" # Enter the bots token leave empty to disable
|
||||
BotChannelId: 0 # Enter the channel id for 2way chat (usualy the LobbyChannel id)
|
||||
AdminChannel: # Discord Webhook for admin reports
|
||||
Webhook: ""
|
||||
ThreadId: # leave empty to post into the channel
|
||||
|
|
|
|||
38
LobbyServer2/LobbyServer/Discord/DiscordBotConfiguration.cs
Normal file
38
LobbyServer2/LobbyServer/Discord/DiscordBotConfiguration.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.IO;
|
||||
using log4net;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace CentralServer.LobbyServer.Discord
|
||||
{
|
||||
public class DiscordBotConfiguration
|
||||
{
|
||||
private static readonly ILog log = LogManager.GetLogger(typeof(DiscordBotConfiguration));
|
||||
private const string ConfigPath = "Config/discordBot.yaml";
|
||||
|
||||
private static DiscordBotConfiguration Instance;
|
||||
|
||||
public bool Enabled = false;
|
||||
public string BotToken = "";
|
||||
public ulong? BotChannelId;
|
||||
public ulong? RequestChannelId;
|
||||
|
||||
public static DiscordBotConfiguration Get()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
var deserializer = new DeserializerBuilder().Build();
|
||||
Instance = deserializer.Deserialize<DiscordBotConfiguration>(File.ReadAllText(ConfigPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
log.Info($"{ConfigPath} not found, Discord bot is disabled");
|
||||
Instance = new DiscordBotConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
return Instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CentralServer.LobbyServer.Chat;
|
||||
|
|
@ -6,7 +7,10 @@ using CentralServer.LobbyServer.Session;
|
|||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.WebSocket;
|
||||
using EvoS.DirectoryServer.Account;
|
||||
using EvoS.Framework.Constants.Enums;
|
||||
using EvoS.Framework.DataAccess;
|
||||
using EvoS.Framework.DataAccess.Daos;
|
||||
using EvoS.Framework.Network.NetworkMessages;
|
||||
using log4net;
|
||||
using Newtonsoft.Json;
|
||||
|
|
@ -21,15 +25,18 @@ namespace CentralServer.LobbyServer.Discord
|
|||
private const string CMD_BROADCAST = "broadcast";
|
||||
private const string CMD_QUEUE_DISABLE = "qoff";
|
||||
private const string CMD_QUEUE_ENABLE = "qon";
|
||||
|
||||
private const string CMD_REQUEST_NAME = "register";
|
||||
private const string CMD_GET_CODE = "code";
|
||||
|
||||
private readonly DiscordSocketClient botClient;
|
||||
private static readonly DiscordSocketConfig discordConfig = new DiscordSocketConfig
|
||||
{
|
||||
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
|
||||
};
|
||||
private readonly ulong? botChannelId;
|
||||
|
||||
public DiscordBotWrapper(DiscordConfiguration conf)
|
||||
private readonly ulong? requestChannelId;
|
||||
|
||||
public DiscordBotWrapper(DiscordBotConfiguration conf)
|
||||
{
|
||||
log.Info("Discord bot is enabled");
|
||||
botClient = new DiscordSocketClient(discordConfig);
|
||||
|
|
@ -42,13 +49,14 @@ namespace CentralServer.LobbyServer.Discord
|
|||
log.Info("Discord bot lobby channel is enabled");
|
||||
botChannelId = conf.BotChannelId;
|
||||
}
|
||||
requestChannelId = conf.RequestChannelId is null or 0 ? null : conf.RequestChannelId;
|
||||
botClient.Log += Log;
|
||||
botClient.Ready += Ready;
|
||||
botClient.SlashCommandExecuted += SlashCommandHandler;
|
||||
botClient.MessageReceived += ClientOnMessageReceived;
|
||||
}
|
||||
|
||||
public async Task Login(DiscordConfiguration conf)
|
||||
public async Task Login(DiscordBotConfiguration conf)
|
||||
{
|
||||
await botClient.LoginAsync(TokenType.Bot, conf.BotToken);
|
||||
await botClient.StartAsync();
|
||||
|
|
@ -81,12 +89,25 @@ namespace CentralServer.LobbyServer.Discord
|
|||
.WithDefaultMemberPermissions(GuildPermission.ManageGuild)
|
||||
.Build();
|
||||
|
||||
SlashCommandProperties requestNameCommand = new SlashCommandBuilder()
|
||||
.WithName(CMD_REQUEST_NAME)
|
||||
.WithDescription("Request a username to register an account")
|
||||
.AddOption("name", ApplicationCommandOptionType.String, "The username you want", true)
|
||||
.Build();
|
||||
|
||||
SlashCommandProperties getCodeCommand = new SlashCommandBuilder()
|
||||
.WithName(CMD_GET_CODE)
|
||||
.WithDescription("Get the registration code for your approved username request")
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.CreateGlobalApplicationCommandAsync(infoCommand);
|
||||
await botClient.CreateGlobalApplicationCommandAsync(broadcastCommand);
|
||||
await botClient.CreateGlobalApplicationCommandAsync(queueDisableCommand);
|
||||
await botClient.CreateGlobalApplicationCommandAsync(queueEnableCommand);
|
||||
await botClient.CreateGlobalApplicationCommandAsync(requestNameCommand);
|
||||
await botClient.CreateGlobalApplicationCommandAsync(getCodeCommand);
|
||||
}
|
||||
catch (HttpException exception)
|
||||
{
|
||||
|
|
@ -165,9 +186,150 @@ namespace CentralServer.LobbyServer.Discord
|
|||
await command.RespondAsync("Matchmaking queue is unpaused", ephemeral: true);
|
||||
break;
|
||||
}
|
||||
case CMD_REQUEST_NAME:
|
||||
{
|
||||
await HandleRequestName(command, handle);
|
||||
break;
|
||||
}
|
||||
case CMD_GET_CODE:
|
||||
{
|
||||
await HandleGetCode(command, handle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsWrongChannel(SocketSlashCommand command)
|
||||
{
|
||||
if (requestChannelId.HasValue && command.ChannelId != requestChannelId)
|
||||
{
|
||||
await command.RespondAsync(
|
||||
$"Please use this command in <#{requestChannelId}>.",
|
||||
ephemeral: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task HandleRequestName(SocketSlashCommand command, string handle)
|
||||
{
|
||||
if (await IsWrongChannel(command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string name = command.Data.Options.First().Value.ToString()?.Trim() ?? "";
|
||||
log.Info($"CMD /{command.Data.Name} - {handle}: {name}");
|
||||
|
||||
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
|
||||
|
||||
// One active request/code per Discord user.
|
||||
RegistrationCodeDao.RegistrationCodeEntry existing = dao.FindLatestByDiscordUser(command.User.Id);
|
||||
if (existing is { State: RegistrationCodeDao.RegistrationState.Requested })
|
||||
{
|
||||
await command.RespondAsync(
|
||||
$"You already have a pending request for `{existing.IssuedTo}`. Please wait for it to be reviewed.",
|
||||
ephemeral: true);
|
||||
return;
|
||||
}
|
||||
if (existing is { State: RegistrationCodeDao.RegistrationState.Issued, IsValid: true })
|
||||
{
|
||||
await command.RespondAsync(
|
||||
$"You already have an approved code waiting. Use `/{CMD_GET_CODE}` to receive it.",
|
||||
ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!LoginManager.IsValidUsername(name))
|
||||
{
|
||||
await command.RespondAsync(LoginManager.InvalidUsername, ephemeral: true);
|
||||
return;
|
||||
}
|
||||
if (!LoginManager.IsAllowedUsername(name))
|
||||
{
|
||||
await command.RespondAsync(LoginManager.CannotUseThisUsername, ephemeral: true);
|
||||
return;
|
||||
}
|
||||
if (DB.Get().LoginDao.Find(name.ToLower()) is not null)
|
||||
{
|
||||
await command.RespondAsync(LoginManager.UsernameIsAlreadyUsed, ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
SocketGuildUser guildUser = command.User as SocketGuildUser;
|
||||
dao.Save(new RegistrationCodeDao.RegistrationCodeEntry
|
||||
{
|
||||
Code = Guid.NewGuid().ToString(),
|
||||
State = RegistrationCodeDao.RegistrationState.Requested,
|
||||
IssuedTo = name.ToLower(),
|
||||
RequestedAt = DateTime.UtcNow,
|
||||
DiscordUserId = command.User.Id,
|
||||
DiscordUserName = command.User.Username,
|
||||
DiscordDisplayName = guildUser?.DisplayName ?? command.User.Username,
|
||||
DiscordAvatarUrl = command.User.GetAvatarUrl() ?? command.User.GetDefaultAvatarUrl(),
|
||||
DiscordCreatedAt = command.User.CreatedAt.UtcDateTime,
|
||||
DiscordJoinedAt = guildUser?.JoinedAt?.UtcDateTime
|
||||
});
|
||||
|
||||
await command.RespondAsync(
|
||||
$"Your request for `{name}` has been submitted for review. " +
|
||||
"You will be pinged here once it is approved.",
|
||||
ephemeral: true);
|
||||
}
|
||||
|
||||
private async Task HandleGetCode(SocketSlashCommand command, string handle)
|
||||
{
|
||||
if (await IsWrongChannel(command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
log.Info($"CMD /{command.Data.Name} - {handle}");
|
||||
RegistrationCodeDao dao = DB.Get().RegistrationCodeDao;
|
||||
RegistrationCodeDao.RegistrationCodeEntry entry = dao.FindLatestByDiscordUser(command.User.Id);
|
||||
|
||||
if (entry is null || entry.State == RegistrationCodeDao.RegistrationState.Requested)
|
||||
{
|
||||
await command.RespondAsync(
|
||||
$"You do not have an approved code yet. Use `/{CMD_REQUEST_NAME}` first, then wait for approval.",
|
||||
ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.State == RegistrationCodeDao.RegistrationState.Declined)
|
||||
{
|
||||
await command.RespondAsync(
|
||||
$"Your username request was declined: {entry.DeclineReason}",
|
||||
ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.IsUsed)
|
||||
{
|
||||
await command.RespondAsync("You have already registered an account.", ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.HasExpired)
|
||||
{
|
||||
// Re-queue the request so an admin can approve it again.
|
||||
entry.State = RegistrationCodeDao.RegistrationState.Requested;
|
||||
entry.ExpiresAt = default;
|
||||
entry.RequestedAt = DateTime.UtcNow;
|
||||
dao.Save(entry);
|
||||
await command.RespondAsync(
|
||||
"Your registration code has expired. Your request has been sent back for review.",
|
||||
ephemeral: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await command.RespondAsync(
|
||||
$"Your registration code for `{entry.IssuedTo}` is:\n`{entry.Code}`\n" +
|
||||
"Enter this username and code on the registration screen.",
|
||||
ephemeral: true);
|
||||
}
|
||||
|
||||
private static Task Log(LogMessage msg)
|
||||
{
|
||||
return DiscordUtils.Log(log, msg);
|
||||
|
|
@ -191,5 +353,40 @@ namespace CentralServer.LobbyServer.Discord
|
|||
IMessageChannel chnl = botClient.GetChannel(_channelId.Value) as IMessageChannel;
|
||||
return chnl.SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags);
|
||||
}
|
||||
|
||||
private async Task PingRequestChannel(ulong discordUserId, string message)
|
||||
{
|
||||
if (!requestChannelId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(
|
||||
text: message,
|
||||
allowedMentions: new AllowedMentions(AllowedMentionTypes.Users),
|
||||
channelIdOverride: requestChannelId);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.Error($"Failed to ping user {discordUserId} in the request channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PingUsernameRequestApproved(ulong discordUserId, string username)
|
||||
{
|
||||
await PingRequestChannel(
|
||||
discordUserId,
|
||||
$"<@{discordUserId}> your username request for `{username}` has been approved! " +
|
||||
$"Use `/{CMD_GET_CODE}` to receive your registration code.");
|
||||
}
|
||||
|
||||
public async Task PingUsernameRequestDeclined(ulong discordUserId, string reason)
|
||||
{
|
||||
await PingRequestChannel(
|
||||
discordUserId,
|
||||
$"<@{discordUserId}> your username request has been declined: {reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,6 @@ namespace CentralServer.LobbyServer.Discord
|
|||
public DiscordChannel AdminActionLogChannel;
|
||||
public DiscordChannel AdminErrorLogChannel;
|
||||
|
||||
public string BotToken = "";
|
||||
public ulong? BotChannelId;
|
||||
|
||||
public bool AdminEnableUserReports;
|
||||
public ulong? AdminUserReportThreadId;
|
||||
public bool AdminEnableChatAudit;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ namespace CentralServer.LobbyServer.Discord
|
|||
{ totalPlayers = -1, inGame = -1, inQueue = -1 };
|
||||
private DiscordLobbyUtils.Status lastStatus = NO_STATUS;
|
||||
|
||||
public DiscordBotWrapper Bot => discordBot;
|
||||
|
||||
|
||||
public DiscordManager()
|
||||
{
|
||||
|
|
@ -143,21 +145,21 @@ namespace CentralServer.LobbyServer.Discord
|
|||
return;
|
||||
}
|
||||
|
||||
if (conf.BotToken.IsNullOrEmpty())
|
||||
DiscordBotConfiguration botConf = DiscordBotConfiguration.Get();
|
||||
if (!botConf.Enabled || botConf.BotToken.IsNullOrEmpty())
|
||||
{
|
||||
log.Info("Discord bot is not enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (conf.BotToken.Length < 70)
|
||||
if (botConf.BotToken.Length < 70)
|
||||
{
|
||||
log.Error("Discord bot token is invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
// Init bot but we dont use it for anything not yet anyway we just want chat from discord to atlas and commands
|
||||
discordBot = new DiscordBotWrapper(conf);
|
||||
await discordBot.Login(conf);
|
||||
discordBot = new DiscordBotWrapper(botConf);
|
||||
await discordBot.Login(botConf);
|
||||
}
|
||||
|
||||
public void Shutdown()
|
||||
|
|
|
|||
19
Tests/LoginManagerTest.cs
Normal file
19
Tests/LoginManagerTest.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using EvoS.DirectoryServer.Account;
|
||||
|
||||
namespace Tests;
|
||||
|
||||
public class LoginManagerTest
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("abcd", true)] // 4 chars, minimum
|
||||
[InlineData("abc", false)] // 3 chars, too short
|
||||
[InlineData("abcdefghijklmnopqrstuvwx", true)] // 24 chars, maximum
|
||||
[InlineData("abcdefghijklmnopqrstuvwxy", false)] // 25 chars, too long
|
||||
[InlineData("1abc", false)] // must start with a letter
|
||||
[InlineData("ab cd", false)] // no spaces
|
||||
[InlineData("ab-c_1", true)] // dash, underscore, digit allowed
|
||||
public void IsValidUsername_EnforcesLengthAndCharset(string username, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, LoginManager.IsValidUsername(username));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
|
|
@ -11,13 +17,24 @@ import {
|
|||
Tooltip,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import {formatDate, getRegistrationCodes, issueRegistrationCode, RegistrationCodeEntry} from "../../lib/Evos";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {
|
||||
confirmUsernameRequest,
|
||||
declineUsernameRequest,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
getRegistrationCodes,
|
||||
getUsernameRequests,
|
||||
issueRegistrationCode,
|
||||
RegistrationCodeEntry,
|
||||
UsernameRequestEntry
|
||||
} from "../../lib/Evos";
|
||||
import React, {useCallback, useEffect, useState} from "react";
|
||||
import {useAuthHeader} from "react-auth-kit";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {EvosError, processError} from "../../lib/Error";
|
||||
import BaseDialog from "../generic/BaseDialog";
|
||||
import {EvosCard, FlexBox, plainAccountLink} from "../generic/BasicComponents";
|
||||
import DiscordUser from "../generic/DiscordUser";
|
||||
|
||||
export default function IssueRegistrationCode() {
|
||||
const [code, setCode] = useState<string>();
|
||||
|
|
@ -25,9 +42,19 @@ export default function IssueRegistrationCode() {
|
|||
const [codes, setCodes] = useState<RegistrationCodeEntry[]>();
|
||||
const [codesBefore, setCodesBefore] = useState<Date>(new Date());
|
||||
const [error, setError] = useState<EvosError>();
|
||||
const [requests, setRequests] = useState<UsernameRequestEntry[]>();
|
||||
const [requestsError, setRequestsError] = useState<EvosError>();
|
||||
const [requestsRefresh, setRequestsRefresh] = useState<number>(0);
|
||||
const [declineTarget, setDeclineTarget] = useState<UsernameRequestEntry>();
|
||||
const [declineReason, setDeclineReason] = useState<string>("");
|
||||
const authHeader = useAuthHeader()();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRequestsRefresh(x => x + 1);
|
||||
setCodesBefore(new Date(new Date().getTime() + 60000));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
|
|
@ -54,13 +81,72 @@ export default function IssueRegistrationCode() {
|
|||
const abort = new AbortController();
|
||||
getRegistrationCodes(abort, authHeader, codesBefore)
|
||||
.then((resp) => {
|
||||
setError(undefined);
|
||||
setCodes(resp.data.entries);
|
||||
})
|
||||
.catch((error) => processError(error, setError, navigate));
|
||||
.catch((error) => {
|
||||
if (abort.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
processError(error, setError, navigate);
|
||||
});
|
||||
|
||||
return () => abort.abort();
|
||||
}, [authHeader, navigate, codesBefore]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
getUsernameRequests(abort, authHeader)
|
||||
.then((resp) => {
|
||||
setRequestsError(undefined);
|
||||
setRequests(resp.data.entries);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (abort.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
processError(error, setRequestsError, navigate);
|
||||
});
|
||||
|
||||
return () => abort.abort();
|
||||
}, [authHeader, navigate, requestsRefresh]);
|
||||
|
||||
const handleConfirm = (row: UsernameRequestEntry) => {
|
||||
setProcessing(true);
|
||||
const abort = new AbortController();
|
||||
confirmUsernameRequest(abort, authHeader, row.code)
|
||||
.catch(e => processError(e, setRequestsError, navigate))
|
||||
.then(() => {
|
||||
setProcessing(false);
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const submitDecline = () => {
|
||||
if (!declineTarget) {
|
||||
return;
|
||||
}
|
||||
setProcessing(true);
|
||||
const abort = new AbortController();
|
||||
declineUsernameRequest(abort, authHeader, declineTarget.code, declineReason)
|
||||
.catch(e => processError(e, setRequestsError, navigate))
|
||||
.then(() => {
|
||||
setProcessing(false);
|
||||
setDeclineTarget(undefined);
|
||||
setDeclineReason("");
|
||||
refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const dateWithRelative = (ts: string) => (
|
||||
<Box>
|
||||
<div>{formatDate(ts)}</div>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatRelativeTime(ts)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <FlexBox style={{ flexDirection: 'column' }}>
|
||||
<EvosCard variant="outlined">
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate style={{ padding: 4 }}>
|
||||
|
|
@ -87,6 +173,90 @@ export default function IssueRegistrationCode() {
|
|||
</Box>
|
||||
</EvosCard>
|
||||
|
||||
<EvosCard variant="outlined" sx={{ maxWidth: 'none', width: '100%' }}>
|
||||
<Typography variant="h6" sx={{ padding: 1 }}>Username requests</Typography>
|
||||
{requestsError && <Typography sx={{ padding: 1 }}>
|
||||
{`Failed to load requests: ${requestsError.text}${requestsError.description ? `(${requestsError.description})` : ""}`}
|
||||
</Typography>}
|
||||
{requests && requests.length === 0 && <Typography sx={{ padding: 1 }}>No pending requests.</Typography>}
|
||||
{requests && requests.length > 0 &&
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Requested username</TableCell>
|
||||
<TableCell>Discord user</TableCell>
|
||||
<TableCell>Account created</TableCell>
|
||||
<TableCell>Joined server</TableCell>
|
||||
<TableCell>Requested at</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{requests.map((row) => (
|
||||
<TableRow key={row.code}>
|
||||
<TableCell>{row.requestedUsername}</TableCell>
|
||||
<TableCell>
|
||||
<DiscordUser
|
||||
displayName={row.discordDisplayName}
|
||||
userName={row.discordUserName}
|
||||
userId={row.discordUserId}
|
||||
avatarUrl={row.discordAvatarUrl}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{dateWithRelative(row.discordCreatedAt)}</TableCell>
|
||||
<TableCell>{row.discordJoinedAt ? dateWithRelative(row.discordJoinedAt) : "-"}</TableCell>
|
||||
<TableCell>{dateWithRelative(row.requestedAt)}</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
disabled={processing}
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="small"
|
||||
onClick={() => handleConfirm(row)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
disabled={processing}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => setDeclineTarget(row)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
</EvosCard>
|
||||
|
||||
<Dialog open={declineTarget !== undefined} onClose={() => setDeclineTarget(undefined)} fullWidth>
|
||||
<DialogTitle>Decline request for {declineTarget?.requestedUsername}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This message will be sent to the requester on Discord.
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="normal"
|
||||
fullWidth
|
||||
multiline
|
||||
label="Reason"
|
||||
value={declineReason}
|
||||
onChange={(e) => setDeclineReason(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeclineTarget(undefined)}>Cancel</Button>
|
||||
<Button color="error" disabled={processing} onClick={submitDecline}>Decline</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{error && <Typography>{`Failed to load codes: ${error.text}${error.description ? `(${error.description})` : ""}`}</Typography>}
|
||||
{codes &&
|
||||
<Box style={{ margin: "0 auto" }}>
|
||||
|
|
@ -94,6 +264,7 @@ export default function IssueRegistrationCode() {
|
|||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Issued to</TableCell>
|
||||
<TableCell>Discord user</TableCell>
|
||||
<TableCell>Issued by</TableCell>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Issued at</TableCell>
|
||||
|
|
@ -111,6 +282,14 @@ export default function IssueRegistrationCode() {
|
|||
<TableCell>{claimed
|
||||
? plainAccountLink(row.issuedTo, row.issuedToHandle, navigate)
|
||||
: row.issuedToHandle}</TableCell>
|
||||
<TableCell>
|
||||
<DiscordUser
|
||||
displayName={row.discordDisplayName}
|
||||
userName={row.discordUserName}
|
||||
userId={row.discordUserId}
|
||||
avatarUrl={row.discordAvatarUrl}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{plainAccountLink(row.issuedBy, row.issuedByHandle, navigate)}</TableCell>
|
||||
<TableCell>{claimed || expired
|
||||
? <Tooltip title={claimed ? "Claimed" : "Expired"}><span style={{ textDecoration: "line-through"}}>{row.code}</span></Tooltip>
|
||||
|
|
@ -125,4 +304,4 @@ export default function IssueRegistrationCode() {
|
|||
</Box>
|
||||
}
|
||||
</FlexBox>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
evos.admin/src/components/generic/DiscordUser.tsx
Normal file
25
evos.admin/src/components/generic/DiscordUser.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {Avatar, Box, Stack, Typography} from "@mui/material";
|
||||
|
||||
interface DiscordUserProps {
|
||||
displayName?: string | null;
|
||||
userName?: string | null;
|
||||
userId?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export default function DiscordUser({displayName, userName, userId, avatarUrl}: DiscordUserProps) {
|
||||
if (!userId) {
|
||||
return <>-</>;
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Avatar src={avatarUrl ?? undefined} sx={{ width: 28, height: 28 }} />
|
||||
<Box>
|
||||
<div>{displayName}</div>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{userName} · {userId}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,12 +96,32 @@ export interface RegistrationCodeEntry {
|
|||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
usedAt: string;
|
||||
discordUserId: string | null;
|
||||
discordUserName: string | null;
|
||||
discordDisplayName: string | null;
|
||||
discordAvatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RegistrationCodesResponse {
|
||||
entries: RegistrationCodeEntry[];
|
||||
}
|
||||
|
||||
export interface UsernameRequestEntry {
|
||||
code: string;
|
||||
requestedUsername: string;
|
||||
discordUserId: string;
|
||||
discordUserName: string;
|
||||
discordDisplayName: string;
|
||||
discordAvatarUrl: string;
|
||||
discordCreatedAt: string;
|
||||
discordJoinedAt: string | null;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface UsernameRequestsResponse {
|
||||
entries: UsernameRequestEntry[];
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
players: PlayerData[];
|
||||
}
|
||||
|
|
@ -226,6 +246,33 @@ export function formatDate(ts: string): string {
|
|||
return ts ? new Date(ts).toLocaleString() : "N/A";
|
||||
}
|
||||
|
||||
export function formatRelativeTime(ts: string): string {
|
||||
if (!ts) {
|
||||
return "";
|
||||
}
|
||||
const seconds = Math.max(0, (new Date().getTime() - new Date(ts).getTime()) / 1000);
|
||||
const units: [number, string][] = [
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
[24, "hour"],
|
||||
[7, "day"],
|
||||
[4.34524, "week"],
|
||||
[12, "month"],
|
||||
[Number.POSITIVE_INFINITY, "year"],
|
||||
];
|
||||
let value = seconds;
|
||||
let unit = "second";
|
||||
for (const [size, name] of units) {
|
||||
unit = name;
|
||||
if (value < size) {
|
||||
break;
|
||||
}
|
||||
value /= size;
|
||||
}
|
||||
const rounded = Math.floor(value);
|
||||
return `${rounded} ${unit}${rounded === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
export function cap(txt: string): string {
|
||||
return txt.charAt(0).toUpperCase() + txt.slice(1);
|
||||
}
|
||||
|
|
@ -352,6 +399,26 @@ export function getRegistrationCodes(abort: AbortController, authHeader: string,
|
|||
{ params: { before: Math.floor(before.getTime() / 1000) }, headers: { 'Authorization': authHeader }, signal: abort.signal });
|
||||
}
|
||||
|
||||
export function getUsernameRequests(abort: AbortController, authHeader: string) {
|
||||
return axios.get<UsernameRequestsResponse>(
|
||||
baseUrl + "/api/admin/player/usernameRequests",
|
||||
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
|
||||
}
|
||||
|
||||
export function confirmUsernameRequest(abort: AbortController, authHeader: string, code: string) {
|
||||
return axios.post(
|
||||
baseUrl + "/api/admin/player/usernameRequest/confirm",
|
||||
{ code: code },
|
||||
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
|
||||
}
|
||||
|
||||
export function declineUsernameRequest(abort: AbortController, authHeader: string, code: string, reason: string) {
|
||||
return axios.post(
|
||||
baseUrl + "/api/admin/player/usernameRequest/decline",
|
||||
{ code: code, reason: reason },
|
||||
{ headers: { 'Authorization': authHeader }, signal: abort.signal });
|
||||
}
|
||||
|
||||
export function generateTempPassword(abort: AbortController, authHeader: string, accountId: number) {
|
||||
return axios.post<RegistrationCodeResponse>(
|
||||
baseUrl + "/api/admin/player/generateTempPassword",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue