diff --git a/Framework/Intersect.Framework.Core/Config/Options.cs b/Framework/Intersect.Framework.Core/Config/Options.cs index 5fa9ce53ad..d37761b416 100644 --- a/Framework/Intersect.Framework.Core/Config/Options.cs +++ b/Framework/Intersect.Framework.Core/Config/Options.cs @@ -10,6 +10,22 @@ namespace Intersect; public partial record Options { + private static readonly JsonSerializerSettings PrivateIndentedSerializerSettings = new() + { + ContractResolver = new OptionsContractResolver(true, false), + Formatting = Formatting.Indented, + }; + + private static readonly JsonSerializerSettings PrivateSerializerSettings = new() + { + ContractResolver = new OptionsContractResolver(true, false), + }; + + private static readonly JsonSerializerSettings PublicSerializerSettings = new() + { + ContractResolver = new OptionsContractResolver(false, true), + }; + #region Constants public const string DefaultGameName = "Intersect"; @@ -48,10 +64,6 @@ public partial record Options [JsonIgnore] public string OptionsData { get; private set; } = string.Empty; - [Ignore] - [JsonIgnore] - public bool SendingToClient { get; set; } = true; - [Ignore] public bool SmtpValid { get; private set; } @@ -235,7 +247,8 @@ public static bool LoadFromDisk() } else if (File.Exists(pathToServerConfig)) { - instance = JsonConvert.DeserializeObject(File.ReadAllText(pathToServerConfig)) ?? instance; + var rawJson = File.ReadAllText(pathToServerConfig); + instance = JsonConvert.DeserializeObject(rawJson, PrivateSerializerSettings) ?? instance; Instance = instance; } @@ -269,13 +282,10 @@ public static void SaveToDisk() var pathToServerConfig = Path.Combine(ResourcesDirectory, "config.json"); - instance.SendingToClient = false; try { - File.WriteAllText( - pathToServerConfig, - JsonConvert.SerializeObject(instance, Formatting.Indented) - ); + var serializedPrivateConfiguration = JsonConvert.SerializeObject(instance, PrivateIndentedSerializerSettings); + File.WriteAllText(pathToServerConfig, serializedPrivateConfiguration); } catch (Exception exception) { @@ -285,15 +295,15 @@ public static void SaveToDisk() pathToServerConfig ); } - instance.SendingToClient = true; - instance.OptionsData = JsonConvert.SerializeObject(instance); + + instance.OptionsData = JsonConvert.SerializeObject(instance, PublicSerializerSettings); } public static void LoadFromServer(string data) { try { - var loadedOptions = JsonConvert.DeserializeObject(data); + var loadedOptions = JsonConvert.DeserializeObject(data, PublicSerializerSettings); Instance = loadedOptions; OptionsLoaded?.Invoke(loadedOptions); } @@ -306,47 +316,7 @@ public static void LoadFromServer(string data) public static event OptionsLoadedEventHandler? OptionsLoaded; - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeGameDatabase() - { - return !SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeLoggingDatabase() - { - return !SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeLogging() - { - return !SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializePlayerDatabase() - { - return !SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeSmtpSettings() - { - return !SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeSmtpValid() - { - return SendingToClient; - } - - // ReSharper disable once UnusedMember.Global - public bool ShouldSerializeSecurity() - { - return !SendingToClient; - } - - public Options DeepClone() => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(this with { SendingToClient = false })); + public Options DeepClone() => JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(this, PrivateSerializerSettings) + ); } \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/Config/OptionsContractResolver.cs b/Framework/Intersect.Framework.Core/Config/OptionsContractResolver.cs new file mode 100644 index 0000000000..b8d61ee631 --- /dev/null +++ b/Framework/Intersect.Framework.Core/Config/OptionsContractResolver.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Intersect; + +public sealed class OptionsContractResolver(bool serializePrivateProperties, bool serializePublicProperties) : DefaultContractResolver +{ + private readonly bool _serializePrivateProperties = serializePrivateProperties; + private readonly bool _serializePublicProperties = serializePublicProperties; + + private static readonly HashSet PrivateProperties = + [ + typeof(Options).GetProperty(nameof(Options.AdminOnly)), + typeof(Options).GetProperty(nameof(Options.EventWatchdogKillThreshold)), + typeof(Options).GetProperty(nameof(Options.GameDatabase)), + typeof(Options).GetProperty(nameof(Options.Logging)), + typeof(Options).GetProperty(nameof(Options.LoggingDatabase)), + typeof(Options).GetProperty(nameof(Options.MaxClientConnections)), + typeof(Options).GetProperty(nameof(Options.MaximumLoggedInUsers)), + typeof(Options).GetProperty(nameof(Options.Metrics)), + typeof(Options).GetProperty(nameof(Options.OpenPortChecker)), + typeof(Options).GetProperty(nameof(Options.PlayerDatabase)), + typeof(Options).GetProperty(nameof(Options.PortCheckerUrl)), + typeof(Options).GetProperty(nameof(Options.Security)), + typeof(Options).GetProperty(nameof(Options.ServerPort)), + typeof(Options).GetProperty(nameof(Options.SmtpSettings)), + typeof(Options).GetProperty(nameof(Options.UPnP)), + typeof(Options).GetProperty(nameof(Options.ValidPasswordResetTimeMinutes)), + ]; + + private static readonly HashSet PublicProperties = + [ + typeof(Options).GetProperty(nameof(Options.SmtpValid)), + ]; + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + + if (PrivateProperties.Contains(member)) + { + property.ShouldDeserialize = AlwaysSerialize; + property.ShouldSerialize = ShouldSerializePrivateProperty; + property.Writable = true; + } + + if (PublicProperties.Contains(member)) + { + property.ShouldDeserialize = AlwaysSerialize; + property.ShouldSerialize = ShouldSerializePublicProperty; + property.Writable = true; + } + + return property; + } + + private static bool AlwaysSerialize(object _) => true; + + private bool ShouldSerializePublicProperty(object _) => _serializePublicProperties; + + private bool ShouldSerializePrivateProperty(object _) => _serializePrivateProperties; +} \ No newline at end of file diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Client/PasswordChangeRequestPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Client/PasswordChangeRequestPacket.cs new file mode 100644 index 0000000000..9a051546f3 --- /dev/null +++ b/Framework/Intersect.Framework.Core/Network/Packets/Client/PasswordChangeRequestPacket.cs @@ -0,0 +1,29 @@ +using MessagePack; + +namespace Intersect.Network.Packets.Client; + +[MessagePackObject] +public partial class PasswordChangeRequestPacket : IntersectPacket +{ + //Parameterless Constructor for MessagePack + public PasswordChangeRequestPacket() + { + } + + public PasswordChangeRequestPacket(string identifier, string token, string password) + { + Identifier = identifier; + Token = token; + Password = password; + } + + [Key(0)] + public string? Identifier { get; set; } + + [Key(1)] + public string? Token { get; set; } + + [Key(2)] + public string? Password { get; set; } + +} diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Client/ResetPasswordPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Client/ResetPasswordPacket.cs deleted file mode 100644 index 2b7f2e44eb..0000000000 --- a/Framework/Intersect.Framework.Core/Network/Packets/Client/ResetPasswordPacket.cs +++ /dev/null @@ -1,29 +0,0 @@ -using MessagePack; - -namespace Intersect.Network.Packets.Client; - -[MessagePackObject] -public partial class ResetPasswordPacket : IntersectPacket -{ - //Parameterless Constructor for MessagePack - public ResetPasswordPacket() - { - } - - public ResetPasswordPacket(string nameOrEmail, string resetCode, string newPassword) - { - NameOrEmail = nameOrEmail; - ResetCode = resetCode; - NewPassword = newPassword; - } - - [Key(0)] - public string NameOrEmail { get; set; } - - [Key(1)] - public string ResetCode { get; set; } - - [Key(2)] - public string NewPassword { get; set; } - -} diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Client/CreateAccountPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Client/UserRegistrationRequestPacket.cs similarity index 67% rename from Framework/Intersect.Framework.Core/Network/Packets/Client/CreateAccountPacket.cs rename to Framework/Intersect.Framework.Core/Network/Packets/Client/UserRegistrationRequestPacket.cs index 3827899266..c49a0238c6 100644 --- a/Framework/Intersect.Framework.Core/Network/Packets/Client/CreateAccountPacket.cs +++ b/Framework/Intersect.Framework.Core/Network/Packets/Client/UserRegistrationRequestPacket.cs @@ -3,14 +3,14 @@ namespace Intersect.Network.Packets.Client; [MessagePackObject] -public partial class CreateAccountPacket : IntersectPacket +public partial class UserRegistrationRequestPacket : IntersectPacket { //Parameterless Constructor for MessagePack - public CreateAccountPacket() + public UserRegistrationRequestPacket() { } - public CreateAccountPacket(string username, string password, string email) + public UserRegistrationRequestPacket(string username, string password, string email) { Username = username; Password = password; diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Server/CharactersPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Server/CharactersPacket.cs index 34b48efa30..3da2f72b25 100644 --- a/Framework/Intersect.Framework.Core/Network/Packets/Server/CharactersPacket.cs +++ b/Framework/Intersect.Framework.Core/Network/Packets/Server/CharactersPacket.cs @@ -10,16 +10,20 @@ public CharactersPacket() { } - public CharactersPacket(CharacterPacket[] characters, bool freeSlot) + public CharactersPacket(string username, CharacterPacket[] characters, bool freeSlot) { + Username = username; Characters = characters; FreeSlot = freeSlot; } [Key(0)] - public CharacterPacket[] Characters { get; set; } + public string Username { get; set; } [Key(1)] + public CharacterPacket[] Characters { get; set; } + + [Key(2)] public bool FreeSlot { get; set; } } diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordChangeResultPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordChangeResultPacket.cs new file mode 100644 index 0000000000..0c8100665f --- /dev/null +++ b/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordChangeResultPacket.cs @@ -0,0 +1,23 @@ +using Intersect.Framework.Core.Security; +using MessagePack; + +namespace Intersect.Network.Packets.Server; + +[MessagePackObject] +public partial class PasswordChangeResultPacket : IntersectPacket +{ + + //Parameterless Constructor for MessagePack + public PasswordChangeResultPacket() + { + } + + public PasswordChangeResultPacket(PasswordResetResultType succeeded) + { + ResultType = succeeded; + } + + [Key(0)] + public PasswordResetResultType ResultType { get; set; } + +} diff --git a/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordResetResultPacket.cs b/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordResetResultPacket.cs deleted file mode 100644 index ad27b605a2..0000000000 --- a/Framework/Intersect.Framework.Core/Network/Packets/Server/PasswordResetResultPacket.cs +++ /dev/null @@ -1,21 +0,0 @@ -using MessagePack; - -namespace Intersect.Network.Packets.Server; - -[MessagePackObject] -public partial class PasswordResetResultPacket : IntersectPacket -{ - //Parameterless Constructor for MessagePack - public PasswordResetResultPacket() - { - } - - public PasswordResetResultPacket(bool succeeded) - { - Succeeded = succeeded; - } - - [Key(0)] - public bool Succeeded { get; set; } - -} diff --git a/Framework/Intersect.Framework.Core/Point.cs b/Framework/Intersect.Framework.Core/Point.cs index 7e0941a405..f85c2ed67a 100644 --- a/Framework/Intersect.Framework.Core/Point.cs +++ b/Framework/Intersect.Framework.Core/Point.cs @@ -71,6 +71,10 @@ public static Point FromString(string val) public static Point operator +(Point left, Point right) => new(left.X + right.X, left.Y + right.Y); + public static Vector2 operator +(Point left, Vector2 right) => new(left.X + right.X, left.Y + right.Y); + + public static Vector2 operator +(Vector2 left, Point right) => new(left.X + right.X, left.Y + right.Y); + public static Point operator -(Point left, Point right) => new(left.X - right.X, left.Y - right.Y); public static Point operator *(Point point, float scalar) => new((int)(point.X * scalar), (int)(point.Y * scalar)); diff --git a/Framework/Intersect.Framework.Core/Security/PasswordResetResultType.cs b/Framework/Intersect.Framework.Core/Security/PasswordResetResultType.cs new file mode 100644 index 0000000000..369a36c3e5 --- /dev/null +++ b/Framework/Intersect.Framework.Core/Security/PasswordResetResultType.cs @@ -0,0 +1,10 @@ +namespace Intersect.Framework.Core.Security; + +public enum PasswordResetResultType +{ + Unknown, + Success, + NoUserFound, + InvalidRequest, + InvalidToken, +} \ No newline at end of file diff --git a/Intersect (Core)/Security/PasswordUtils.cs b/Intersect (Core)/Security/PasswordUtils.cs index c513ec197f..0c75335869 100644 --- a/Intersect (Core)/Security/PasswordUtils.cs +++ b/Intersect (Core)/Security/PasswordUtils.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; @@ -5,11 +6,12 @@ namespace Intersect.Security; public partial class PasswordUtils { - public static string ComputePasswordHash(string password) + [return: NotNull] + public static string ComputePasswordHash(string? password) { return BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(password ?? string.Empty))).Replace("-", string.Empty); } - public static bool IsValidClientPasswordHash(string? hashToValidate) => + public static bool IsValidClientPasswordHash([NotNullWhen(true)] string? hashToValidate) => hashToValidate is { Length: 64 } && hashToValidate.All(char.IsAsciiHexDigit); } diff --git a/Intersect (Core)/Utilities/FieldChecking.cs b/Intersect (Core)/Utilities/FieldChecking.cs index 5e72adfe85..f54a704e2a 100644 --- a/Intersect (Core)/Utilities/FieldChecking.cs +++ b/Intersect (Core)/Utilities/FieldChecking.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; namespace Intersect.Utilities; @@ -16,9 +17,9 @@ public static partial class FieldChecking public const string PATTERN_GUILDNAME = @"^[a-zA-Z0-9 ]{3,20}$"; - public static bool IsWellformedEmailAddress(string email, string emailRegex) + public static bool IsWellformedEmailAddress([NotNullWhen(true)] string? email, string emailRegex) { - if (email == null) + if (string.IsNullOrWhiteSpace(email)) { return false; } @@ -39,9 +40,9 @@ public static bool IsWellformedEmailAddress(string email, string emailRegex) } } - public static bool IsValidUsername(string username, string usernameRegex) + public static bool IsValidUsername([NotNullWhen(true)] string? username, string usernameRegex) { - if (username == null) + if (string.IsNullOrWhiteSpace(username)) { return false; } @@ -62,9 +63,9 @@ public static bool IsValidUsername(string username, string usernameRegex) } } - public static bool IsValidPassword(string password, string passwordRegex) + public static bool IsValidPassword([NotNullWhen(true)] string? password, string passwordRegex) { - if (password == null) + if (string.IsNullOrWhiteSpace(password)) { return false; } diff --git a/Intersect.Client.Core/Interface/Debugging/DebugWindow.cs b/Intersect.Client.Core/Interface/Debugging/DebugWindow.cs index 9ab11f2c02..ba6700cf4a 100644 --- a/Intersect.Client.Core/Interface/Debugging/DebugWindow.cs +++ b/Intersect.Client.Core/Interface/Debugging/DebugWindow.cs @@ -9,6 +9,7 @@ using Intersect.Client.Framework.Gwen.Control.EventArguments; using Intersect.Client.Framework.Gwen.Control.Layout; using Intersect.Client.Framework.Gwen.Control.Utility; +using Intersect.Client.Framework.Gwen.ControlInternal; using Intersect.Client.Framework.Input; using Intersect.Client.General; using Intersect.Client.Interface.Data; @@ -793,8 +794,24 @@ private Table CreateInfoTableDebugStats(Base parent) table.AddRow(Strings.Internals.Color, name: "ColorRow").Listen(1, _nodeUnderCursorProvider, (node, _) => (node as IColorableText)?.TextColor, NoValue); table.AddRow(Strings.Internals.ColorOverride, name: "ColorOverrideRow").Listen(1, _nodeUnderCursorProvider, (node, _) => (node as IColorableText)?.TextColorOverride, NoValue); table.AddRow(Strings.Internals.TextAlign, name: "TextAlign").Listen(1, _nodeUnderCursorProvider, (node, _) => (node as Label)?.TextAlign, NoValue); - table.AddRow(Strings.Internals.Font, name: "Font").Listen(1, _nodeUnderCursorProvider, (node, _) => (node as Label)?.FontName, NoValue); - table.AddRow(Strings.Internals.FontSize, name: "FontSize").Listen(1, _nodeUnderCursorProvider, (node, _) => (node as Label)?.FontSize, NoValue); + table.AddRow(Strings.Internals.Font, name: "Font").Listen(1, _nodeUnderCursorProvider, (node, _) => + { + return node switch + { + Label label => label.FontName, + Text text => text.Font?.Name, + _ => null, + }; + }, NoValue); + table.AddRow(Strings.Internals.FontSize, name: "FontSize").Listen(1, _nodeUnderCursorProvider, (node, _) => + { + return node switch + { + Label label => label.FontSize, + Text text => text.FontSize, + _ => default(int?), + }; + }, NoValue); table.AddRow(Strings.Internals.AutoSizeToContents, name: nameof(IAutoSizeToContents.AutoSizeToContents)).Listen(1, _nodeUnderCursorProvider, (node, _) => (node as IAutoSizeToContents)?.AutoSizeToContents, NoValue); table.AddRow(Strings.Internals.AutoSizeToContentWidth, name: nameof(ISmartAutoSizeToContents.AutoSizeToContentWidth)).Listen(1, _nodeUnderCursorProvider, (node, _) => (node as ISmartAutoSizeToContents)?.AutoSizeToContentWidth, NoValue); table.AddRow(Strings.Internals.AutoSizeToContentHeight, name: nameof(ISmartAutoSizeToContents.AutoSizeToContentHeight)).Listen(1, _nodeUnderCursorProvider, (node, _) => (node as ISmartAutoSizeToContents)?.AutoSizeToContentHeight, NoValue); diff --git a/Intersect.Client.Core/Interface/Menu/ForgotPasswordWindow.cs b/Intersect.Client.Core/Interface/Menu/ForgotPasswordWindow.cs index b57d34e15c..7a1ed7c12b 100644 --- a/Intersect.Client.Core/Interface/Menu/ForgotPasswordWindow.cs +++ b/Intersect.Client.Core/Interface/Menu/ForgotPasswordWindow.cs @@ -1,5 +1,7 @@ using Intersect.Client.Core; using Intersect.Client.Framework.File_Management; +using Intersect.Client.Framework.Graphics; +using Intersect.Client.Framework.Gwen; using Intersect.Client.Framework.Gwen.Control; using Intersect.Client.Framework.Gwen.Control.EventArguments; using Intersect.Client.Framework.Input; @@ -12,137 +14,172 @@ namespace Intersect.Client.Interface.Menu; -public partial class ForgotPasswordWindow +public partial class ForgotPasswordWindow : Window { + private readonly IFont? _defaultFont; - private Button mBackBtn; + private readonly Panel _inputPanel; + private readonly Panel _buttonPanel; - private RichLabel mHintLabel; + private readonly Panel _usernameOrEmailPanel; + private readonly TextBox _usernameOrEmailInput; - private Label mHintLabelTemplate; + private readonly Button _submitButton; + private readonly Button _backButton; - //Controls - private ImagePanel mInputBackground; + private readonly Label _disclaimerTemplate; + private readonly RichLabel _disclaimer; + private readonly ScrollControl _disclaimerScroller; - private Label mInputLabel; - - private TextBox mInputTextbox; - - //Parent - private MainMenu mMainMenu; - - //Controls - private ImagePanel mResetWindow; - - private Button mSubmitBtn; - - private Label mWindowHeader; - - //Init - public ForgotPasswordWindow(Canvas parent, MainMenu mainMenu) + public ForgotPasswordWindow(Canvas parent) : base( + parent, + title: Strings.ForgotPass.Title, + modal: false, + name: nameof(ForgotPasswordWindow) + ) { - //Assign References - mMainMenu = mainMenu; - - //Main Menu Window - mResetWindow = new ImagePanel(parent, "ForgotPasswordWindow"); - mResetWindow.IsHidden = true; - - //Menu Header - mWindowHeader = new Label(mResetWindow, "Header"); - mWindowHeader.SetText(Strings.ForgotPass.Title); - - mInputBackground = new ImagePanel(mResetWindow, "InputPanel"); - - //Login Username Label - mInputLabel = new Label(mInputBackground, "InputLabel"); - mInputLabel.SetText(Strings.ForgotPass.Label); - - //Login Username Textbox - mInputTextbox = new TextBox(mInputBackground, "InputField"); - mInputTextbox.SubmitPressed += Textbox_SubmitPressed; - mInputTextbox.Clicked += Textbox_Clicked; + _defaultFont = GameContentManager.Current.GetFont(name: "sourcesansproblack"); - mHintLabelTemplate = new Label(mResetWindow, "HintLabel"); - mHintLabelTemplate.IsHidden = true; + Alignment = [Alignments.Center]; + MinimumSize = new Point(x: 544, y: 168); + IsClosable = false; + IsResizable = false; + InnerPanelPadding = new Padding(8); + Titlebar.MouseInputEnabled = false; + TitleLabel.FontSize = 14; + TitleLabel.TextColorOverride = Color.White; - //Login - Send Login Button - mSubmitBtn = new Button(mResetWindow, "SubmitButton"); - mSubmitBtn.SetText(Strings.ForgotPass.Submit); - mSubmitBtn.Clicked += SubmitBtn_Clicked; + SkipRender(); - //Login - Back Button - mBackBtn = new Button(mResetWindow, "BackButton"); - mBackBtn.SetText(Strings.ForgotPass.Back); - mBackBtn.Clicked += BackBtn_Clicked; - - mResetWindow.LoadJsonUi(GameContentManager.UI.Menu, Graphics.Renderer.GetResolutionString()); - - mHintLabel = new RichLabel(mResetWindow); - mHintLabel.SetBounds(mHintLabelTemplate.Bounds); - mHintLabelTemplate.IsHidden = false; - mHintLabel.AddText(Strings.ForgotPass.Hint, mHintLabelTemplate); - } - - public bool IsHidden => mResetWindow.IsHidden; + _buttonPanel = new Panel(this, nameof(_buttonPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Right, + DockChildSpacing = new Padding(8), + Margin = new Margin(8, 0, 0, 0), + MinimumSize = new Point(160, 0), + }; + + _submitButton = new Button(_buttonPanel, nameof(_submitButton)) + { + AutoSizeToContents = true, + Dock = Pos.Top, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(160, 24), + Padding = new Padding(8, 4), + Text = Strings.ForgotPass.Submit, + }; + _submitButton.Clicked += SubmitButtonOnClicked; + + _backButton = new Button(_buttonPanel, nameof(_backButton)) + { + AutoSizeToContents = true, + Dock = Pos.Top, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(160, 24), + Padding = new Padding(8, 4), + Text = Strings.ForgotPass.Back, + }; + _backButton.Clicked += BackButtonOnClicked; + + _inputPanel = new Panel(this, nameof(_inputPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Fill, + DockChildSpacing = new Padding(8), + }; - private void Textbox_Clicked(Base sender, MouseButtonState arguments) - { - Globals.InputManager.OpenKeyboard(KeyboardType.Normal, mInputTextbox.Text, false, false, false); - } + _usernameOrEmailPanel = new Panel(_inputPanel, nameof(_usernameOrEmailPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Top, + DockChildSpacing = new Padding(4), + MinimumSize = new Point(0, 28), + }; - //Methods - public void Update() - { - if (!Networking.Network.IsConnected) + _usernameOrEmailInput = new TextBox(_usernameOrEmailPanel, nameof(_usernameOrEmailInput)) { - Hide(); - mMainMenu.Show(); - } + Dock = Pos.Fill, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(240, 0), + Padding = new Padding(4, 2), + PlaceholderText = Strings.ForgotPass.UsernameOrEmailPlaceholder, + TextAlign = Pos.Left | Pos.CenterV, + }; + _usernameOrEmailInput.SubmitPressed += UsernameOrEmailInputOnSubmitPressed; + _usernameOrEmailInput.Clicked += UsernameOrEmailInputOnClicked; + _usernameOrEmailInput.SetSound(TextBox.Sounds.AddText, "octave-tap-resonant.wav"); + _usernameOrEmailInput.SetSound(TextBox.Sounds.RemoveText, "octave-tap-professional.wav"); + _usernameOrEmailInput.SetSound(TextBox.Sounds.Submit, "octave-tap-warm.wav"); + + _disclaimerTemplate = new Label(_inputPanel, nameof(_disclaimerTemplate)) + { + Dock = Pos.Top, + Font = _defaultFont, + IsVisibleInParent = false, + WrappingBehavior = WrappingBehavior.Wrapped, + }; + _disclaimerTemplate.SizeToContents(); + + _disclaimerScroller = new ScrollControl(_inputPanel, nameof(_disclaimerScroller)) + { + Dock = Pos.Fill, + MinimumSize = _disclaimerTemplate.Size + new Point(16, 16), + OverflowX = OverflowBehavior.Hidden, + OverflowY = OverflowBehavior.Auto, + }; - // Re-Enable our buttons if we're not waiting for the server anymore with it disabled. - if (!Globals.WaitingOnServer && mSubmitBtn.IsDisabled) + _disclaimer = new RichLabel(_disclaimerScroller, nameof(_disclaimer)) { - mSubmitBtn.Enable(); - } + Dock = Pos.Fill, + MinimumSize = _disclaimerTemplate.Size, + }; } - public void Hide() + private void UsernameOrEmailInputOnSubmitPressed(TextBox textBox, EventArgs eventArgs) { - mResetWindow.IsHidden = true; + TrySendCode(); } - public void Show() + private void UsernameOrEmailInputOnClicked(Base sender, MouseButtonState arguments) { - mResetWindow.IsHidden = false; - mInputTextbox.Text = string.Empty; + Globals.InputManager.OpenKeyboard( + type: KeyboardType.Normal, + text: _usernameOrEmailInput.Text ?? string.Empty, + autoCorrection: false, + multiLine: false, + secure: false + ); } - void BackBtn_Clicked(Base sender, MouseButtonState arguments) + private void BackButtonOnClicked(Base sender, MouseButtonState arguments) { Hide(); Interface.MenuUi.MainMenu.NotifyOpenLogin(); } - void Textbox_SubmitPressed(Base sender, EventArgs arguments) + private void SubmitButtonOnClicked(Base sender, MouseButtonState arguments) { TrySendCode(); } - void SubmitBtn_Clicked(Base sender, MouseButtonState arguments) + public override void Show() + { + _usernameOrEmailInput.Text = null; + base.Show(); + } + + private void TrySendCode() { if (Globals.WaitingOnServer) { + _submitButton.IsDisabled = true; return; } - TrySendCode(); - - mSubmitBtn.Disable(); - } - - public void TrySendCode() - { if (!Networking.Network.IsConnected) { Interface.ShowAlert(Strings.Errors.NotConnected, alertType: AlertType.Error); @@ -150,15 +187,28 @@ public void TrySendCode() return; } - if (!FieldChecking.IsValidUsername(mInputTextbox?.Text, Strings.Regex.Username) && - !FieldChecking.IsWellformedEmailAddress(mInputTextbox?.Text, Strings.Regex.Email)) + var usernameOrEmail = _usernameOrEmailInput.Text; + if (!FieldChecking.IsValidUsername(usernameOrEmail, Strings.Regex.Username) && + !FieldChecking.IsWellformedEmailAddress(usernameOrEmail, Strings.Regex.Email)) { Interface.ShowAlert(Strings.Errors.UsernameInvalid, alertType: AlertType.Error); return; } - Interface.MenuUi.MainMenu.OpenResetPassword(mInputTextbox?.Text); - PacketSender.SendRequestPasswordReset(mInputTextbox?.Text); + if (string.IsNullOrWhiteSpace(usernameOrEmail)) + { + throw new InvalidOperationException( + "IsValidUsername() and IsWellformedEmailAddress() should have blocked this" + ); + } + + Interface.MenuUi.MainMenu.OpenPasswordChangeWindow(usernameOrEmail, PasswordChangeMode.ResetToken, null); + PacketSender.SendRequestPasswordReset(usernameOrEmail); } + protected override void EnsureInitialized() + { + LoadJsonUi(GameContentManager.UI.Menu, Graphics.Renderer.GetResolutionString()); + _disclaimer.AddText(Strings.ForgotPass.Disclaimer, _disclaimerTemplate); + } } diff --git a/Intersect.Client.Core/Interface/Menu/LoginWindow.cs b/Intersect.Client.Core/Interface/Menu/LoginWindow.cs index 8af1bdb498..90e26c709e 100644 --- a/Intersect.Client.Core/Interface/Menu/LoginWindow.cs +++ b/Intersect.Client.Core/Interface/Menu/LoginWindow.cs @@ -50,7 +50,7 @@ public LoginWindow(Canvas parent, MainMenu mainMenu) : base( _defaultFont = GameContentManager.Current.GetFont(name: "sourcesansproblack"); Alignment = [Alignments.Center]; - MinimumSize = new Point(x: 504, y: 144); + MinimumSize = new Point(x: 544, y: 148); IsClosable = false; IsResizable = false; InnerPanelPadding = new Padding(8); @@ -64,7 +64,7 @@ public LoginWindow(Canvas parent, MainMenu mainMenu) : base( Dock = Pos.Right, DockChildSpacing = new Padding(8), Margin = new Margin(8, 0, 0, 0), - MinimumSize = new Point(120, 0), + MinimumSize = new Point(160, 0), }; _loginButton = new Button(_buttonPanel, "LoginButton") @@ -73,36 +73,35 @@ public LoginWindow(Canvas parent, MainMenu mainMenu) : base( Dock = Pos.Top, Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Padding = new Padding(8, 4), Text = Strings.LoginWindow.Login, }; _loginButton.Clicked += LoginButtonOnClicked; - _forgotPasswordButton = new Button(_buttonPanel, "ForgotPasswordButton") + _backButton = new Button(_buttonPanel, nameof(_backButton)) { AutoSizeToContents = true, - Dock = Pos.Right | Pos.CenterV, + Dock = Pos.Top, Font = _defaultFont, FontSize = 12, - IsHidden = true, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Padding = new Padding(8, 4), - Text = Strings.LoginWindow.ForgotPassword, + Text = Strings.LoginWindow.Back, }; - _forgotPasswordButton.Clicked += ForgotPasswordButtonOnClicked; + _backButton.Clicked += BackButtonOnClicked; - _backButton = new Button(_buttonPanel, nameof(_backButton)) + _forgotPasswordButton = new Button(_buttonPanel, "ForgotPasswordButton") { AutoSizeToContents = true, Dock = Pos.Top, Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Padding = new Padding(8, 4), - Text = Strings.LoginWindow.Back, + Text = Strings.LoginWindow.ForgotPassword, }; - _backButton.Clicked += BackButtonOnClicked; + _forgotPasswordButton.Clicked += ForgotPasswordButtonOnClicked; _inputPanel = new Panel(this, nameof(_inputPanel)) { @@ -199,6 +198,16 @@ private void LoginButtonOnClicked(Base @base, MouseButtonState mouseButtonState) TryLogin(); } + protected override void OnVisibilityChanged(object? sender, VisibilityChangedEventArgs eventArgs) + { + base.OnVisibilityChanged(sender, eventArgs); + + if (eventArgs.IsVisibleInTree) + { + _forgotPasswordButton.IsVisibleInParent = !Options.IsLoaded || Options.Instance.SmtpValid; + } + } + protected override void EnsureInitialized() { LoadCredentials(); diff --git a/Intersect.Client.Core/Interface/Menu/MainMenu.cs b/Intersect.Client.Core/Interface/Menu/MainMenu.cs index 76963de68b..7e6fa791ac 100644 --- a/Intersect.Client.Core/Interface/Menu/MainMenu.cs +++ b/Intersect.Client.Core/Interface/Menu/MainMenu.cs @@ -3,6 +3,7 @@ using Intersect.Client.Framework.Gwen; using Intersect.Client.Framework.Gwen.Control; using Intersect.Client.Interface.Shared; +using Intersect.Framework; using Intersect.Framework.Core; using Intersect.Network; @@ -17,6 +18,8 @@ public partial class MainMenu : MutableInterface private bool _shouldOpenCharacterSelection; private bool _forceCharacterCreation; + private string? _username; + // Network status public static NetworkStatus ActiveNetworkStatus { get; set; } @@ -47,21 +50,14 @@ public partial class MainMenu : MutableInterface private ForgotPasswordWindow? _forgotPasswordWindow; - private ForgotPasswordWindow ForgotPasswordWindow => _forgotPasswordWindow ??= new ForgotPasswordWindow(_menuCanvas, this) + private ForgotPasswordWindow ForgotPasswordWindow => _forgotPasswordWindow ??= new ForgotPasswordWindow(_menuCanvas) { // Alignment = [Alignments.CenterH], // Y = 480, // IsVisible = false, }; - private ResetPasswordWindow? _resetPasswordWindow; - - private ResetPasswordWindow ResetPasswordWindow => _resetPasswordWindow ??= new ResetPasswordWindow(_menuCanvas, this) - { - // Alignment = [Alignments.CenterH], - // Y = 480, - // IsVisible = false, - }; + private PasswordChangeWindow? _passwordChangeWindow; private CharacterCreationWindow? _characterCreationWindow; @@ -179,7 +175,10 @@ public void Reset() RegistrationWindow.Hide(); CreditsWindow.Hide(); ForgotPasswordWindow.Hide(); - ResetPasswordWindow.Hide(); + + _passwordChangeWindow?.DelayedDelete(); + _passwordChangeWindow = null; + CharacterCreationWindow.Hide(); SelectCharacterWindow.Hide(); _mainMenuWindow.Show(); @@ -190,8 +189,12 @@ public void Reset() private void Hide() => _mainMenuWindow.Hide(); - public void NotifyOpenCharacterSelection(List characterSelectionPreviews) + public void NotifyOpenCharacterSelection( + List characterSelectionPreviews, + string username + ) { + _username = username; _shouldOpenCharacterSelection = true; SelectCharacterWindow.CharacterSelectionPreviews = [..characterSelectionPreviews]; } @@ -210,12 +213,43 @@ public void NotifyOpenLogin() LoginWindow.Show(); } - public void OpenResetPassword(string nameEmail) + public void OpenPasswordChangeWindow(string? identifier, PasswordChangeMode changeMode, Window? previousWindow) { Reset(); Hide(); - ResetPasswordWindow.Target = nameEmail; - ResetPasswordWindow.Show(); + + _passwordChangeWindow?.Dispose(); + + identifier ??= _username; + + previousWindow ??= changeMode switch + { + PasswordChangeMode.ResetToken => LoginWindow, + PasswordChangeMode.ExistingPassword => SelectCharacterWindow, + _ => throw Exceptions.UnreachableInvalidEnum(changeMode), + }; + + _passwordChangeWindow = new PasswordChangeWindow( + _menuCanvas, + this, + previousWindow, + changeMode + ) + { + // Alignment = [Alignments.CenterH], + // Y = 480, + // IsVisible = false, + Target = identifier, + }; + _passwordChangeWindow.Disposed += PasswordChangeWindowOnDisposed; + } + + private void PasswordChangeWindowOnDisposed(Base sender, EventArgs _) + { + if (sender == _passwordChangeWindow) + { + _passwordChangeWindow = null; + } } private void CreateCharacterSelection() diff --git a/Intersect.Client.Core/Interface/Menu/PasswordChangeMode.cs b/Intersect.Client.Core/Interface/Menu/PasswordChangeMode.cs new file mode 100644 index 0000000000..b58216f732 --- /dev/null +++ b/Intersect.Client.Core/Interface/Menu/PasswordChangeMode.cs @@ -0,0 +1,7 @@ +namespace Intersect.Client.Interface.Menu; + +public enum PasswordChangeMode +{ + ResetToken, + ExistingPassword, +} \ No newline at end of file diff --git a/Intersect.Client.Core/Interface/Menu/PasswordChangeWindow.cs b/Intersect.Client.Core/Interface/Menu/PasswordChangeWindow.cs new file mode 100644 index 0000000000..ed46cc9940 --- /dev/null +++ b/Intersect.Client.Core/Interface/Menu/PasswordChangeWindow.cs @@ -0,0 +1,372 @@ +using Intersect.Client.Core; +using Intersect.Client.Framework.File_Management; +using Intersect.Client.Framework.Graphics; +using Intersect.Client.Framework.Gwen; +using Intersect.Client.Framework.Gwen.Control; +using Intersect.Client.Framework.Gwen.Control.EventArguments; +using Intersect.Client.Framework.Input; +using Intersect.Client.General; +using Intersect.Client.Interface.Game.Chat; +using Intersect.Client.Interface.Shared; +using Intersect.Client.Localization; +using Intersect.Client.Networking; +using Intersect.Framework; +using Intersect.Security; +using Intersect.Utilities; + +namespace Intersect.Client.Interface.Menu; + +public partial class PasswordChangeWindow : Window +{ + private readonly MainMenu _mainMenu; + private readonly Window _previousWindow; + private readonly IFont? _defaultFont; + + private readonly Panel _inputPanel; + private readonly Panel _buttonPanel; + + private readonly Button _submitButton; + private readonly Button _backButton; + + private readonly Panel _tokenPanel; + private readonly Label _tokenLabel; + private readonly TextBox _tokenInput; + + private readonly Panel _passwordPanel; + private readonly Label _passwordLabel; + private readonly TextBoxPassword _passwordInput; + + private readonly Panel _passwordConfirmationPanel; + private readonly Label _passwordConfirmationLabel; + private readonly TextBoxPassword _passwordConfirmationInput; + + private readonly PasswordChangeMode _changeMode; + + public PasswordChangeWindow(Canvas parent, MainMenu mainMenu, Window previousWindow, PasswordChangeMode passwordChangeMode) : base( + parent, + title: Strings.PasswordChange.Title, + modal: false, + name: $"{nameof(PasswordChangeWindow)}{passwordChangeMode}" + ) + { + _mainMenu = mainMenu; + _previousWindow = previousWindow; + _changeMode = passwordChangeMode; + + _defaultFont = GameContentManager.Current.GetFont(name: "sourcesansproblack"); + + Alignment = [Alignments.Center]; + MinimumSize = new Point(x: 600, y: 148); + IsClosable = false; + IsResizable = false; + InnerPanelPadding = new Padding(8); + Titlebar.MouseInputEnabled = false; + TitleLabel.FontSize = 14; + TitleLabel.TextColorOverride = Color.White; + + SkipRender(); + + _buttonPanel = new Panel(this, nameof(_buttonPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Right, + DockChildSpacing = new Padding(8), + Margin = new Margin(8, 0, 0, 0), + MinimumSize = new Point(120, 0), + }; + + _submitButton = new Button(_buttonPanel, nameof(_submitButton)) + { + AutoSizeToContents = true, + Dock = Pos.Top, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(120, 24), + Padding = new Padding(8, 4), + TabOrder = 4, + Text = Strings.ForgotPass.Submit, + }; + _submitButton.Clicked += SubmitButtonOnClicked; + + _backButton = new Button(_buttonPanel, nameof(_backButton)) + { + AutoSizeToContents = true, + Dock = Pos.Top, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(120, 24), + Padding = new Padding(8, 4), + TabOrder = 5, + Text = Strings.ForgotPass.Back, + }; + _backButton.Clicked += BackButtonOnClicked; + + _inputPanel = new Panel(this, nameof(_inputPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Fill, + DockChildSpacing = new Padding(8), + }; + + _tokenPanel = new Panel(_inputPanel, nameof(_tokenPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Top, + DockChildSpacing = new Padding(4), + MinimumSize = new Point(0, 28), + }; + + var tokenLabel = _changeMode switch + { + PasswordChangeMode.ResetToken => Strings.PasswordChange.InputLabelResetCode, + PasswordChangeMode.ExistingPassword => Strings.PasswordChange.InputLabelPassword, + _ => throw Exceptions.UnreachableInvalidEnum(_changeMode), + }; + _tokenLabel = new Label(_tokenPanel, nameof(_passwordLabel)) + { + AutoSizeToContents = false, + Dock = Pos.Fill, + Font = _defaultFont, + FontSize = 12, + Padding = new Padding(0, 0, 10, 0), + Text = tokenLabel, + TextAlign = Pos.Right | Pos.CenterV, + }; + + _tokenInput = CreateTokenInput(_defaultFont, _tokenPanel, _changeMode); + _tokenInput.SubmitPressed += InputTextboxOnSubmitPressed; + _tokenInput.Clicked += InputTextboxOnClicked; + _tokenInput.SetSound(TextBox.Sounds.AddText, "octave-tap-resonant.wav"); + _tokenInput.SetSound(TextBox.Sounds.RemoveText, "octave-tap-professional.wav"); + _tokenInput.SetSound(TextBox.Sounds.Submit, "octave-tap-warm.wav"); + + _passwordPanel = new Panel(_inputPanel, nameof(_passwordPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Top, + DockChildSpacing = new Padding(4), + MinimumSize = new Point(0, 28), + }; + + _passwordLabel = new Label(_passwordPanel, nameof(_passwordLabel)) + { + AutoSizeToContents = false, + Dock = Pos.Fill, + Font = _defaultFont, + FontSize = 12, + Padding = new Padding(0, 0, 10, 0), + Text = Strings.PasswordChange.NewPassword, + TextAlign = Pos.Right | Pos.CenterV, + }; + + _passwordInput = new TextBoxPassword(_passwordPanel, nameof(_passwordInput)) + { + Dock = Pos.Right, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(240, 0), + Padding = new Padding(4, 2), + TabOrder = 2, + TextAlign = Pos.Left | Pos.CenterV, + }; + _passwordInput.SubmitPressed += InputTextboxOnSubmitPressed; + _passwordInput.Clicked += InputTextboxOnClicked; + _passwordInput.SetSound(TextBox.Sounds.AddText, "octave-tap-resonant.wav"); + _passwordInput.SetSound(TextBox.Sounds.RemoveText, "octave-tap-professional.wav"); + _passwordInput.SetSound(TextBox.Sounds.Submit, "octave-tap-warm.wav"); + + _passwordConfirmationPanel = new Panel(_inputPanel, nameof(_passwordConfirmationPanel)) + { + BackgroundColor = Color.Transparent, + Dock = Pos.Top, + DockChildSpacing = new Padding(4), + MinimumSize = new Point(0, 28), + }; + + _passwordConfirmationLabel = new Label(_passwordConfirmationPanel, nameof(_passwordConfirmationLabel)) + { + AutoSizeToContents = false, + Dock = Pos.Fill, + Font = _defaultFont, + FontSize = 12, + Padding = new Padding(0, 0, 10, 0), + Text = Strings.PasswordChange.ConfirmNewPassword, + TextAlign = Pos.Right | Pos.CenterV, + }; + + _passwordConfirmationInput = new TextBoxPassword(_passwordConfirmationPanel, nameof(_passwordConfirmationInput)) + { + Dock = Pos.Right, + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(240, 0), + Padding = new Padding(4, 2), + TabOrder = 3, + TextAlign = Pos.Left | Pos.CenterV, + }; + _passwordConfirmationInput.SubmitPressed += InputTextboxOnSubmitPressed; + _passwordConfirmationInput.Clicked += InputTextboxOnClicked; + _passwordConfirmationInput.SetSound(TextBox.Sounds.AddText, "octave-tap-resonant.wav"); + _passwordConfirmationInput.SetSound(TextBox.Sounds.RemoveText, "octave-tap-professional.wav"); + _passwordConfirmationInput.SetSound(TextBox.Sounds.Submit, "octave-tap-warm.wav"); + } + + private static TextBox CreateTokenInput(IFont? font, Panel tokenPanel, PasswordChangeMode changeMode) + { + return changeMode switch + { + PasswordChangeMode.ResetToken => new TextBox(tokenPanel, nameof(_passwordInput)) + { + Dock = Pos.Right, + Font = font, + FontSize = 12, + IsTabable = true, + MinimumSize = new Point(240, 0), + Padding = new Padding(4, 2), + TabOrder = 1, + TextAlign = Pos.Left | Pos.CenterV, + }, + PasswordChangeMode.ExistingPassword => new TextBoxPassword(tokenPanel, nameof(_passwordInput)) + { + Dock = Pos.Right, + Font = font, + FontSize = 12, + MinimumSize = new Point(240, 0), + Padding = new Padding(4, 2), + TabOrder = 1, + TextAlign = Pos.Left | Pos.CenterV, + }, + _ => throw Exceptions.UnreachableInvalidEnum(changeMode), + }; + } + + private void BackButtonOnClicked(Base sender, MouseButtonState arguments) => ReturnToPreviousWindow(); + + private void SubmitButtonOnClicked(Base sender, MouseButtonState arguments) + { + TrySendRequest(); + } + + private void InputTextboxOnSubmitPressed(Base sender, EventArgs arguments) + { + TrySendRequest(); + } + + //The username or email of the acc we are resetting the pass for + public string? Target { set; get; } + + private void InputTextboxOnClicked(Base sender, MouseButtonState arguments) + { + if (sender is not TextBox textBox) + { + return; + } + + var isPassword = textBox is TextBoxPassword; + Globals.InputManager.OpenKeyboard( + type: isPassword ? KeyboardType.Password : KeyboardType.Normal, + text: textBox.Text ?? string.Empty, + autoCorrection: false, + multiLine: false, + secure: isPassword + ); + } + + private void ReturnToPreviousWindow() + { + _previousWindow.Show(); + DelayedDelete(); + } + + private void ReturnToMainMenu() + { + _mainMenu.Show(); + DelayedDelete(); + } + + public override void Show() + { + _tokenInput.Text = null; + _passwordInput.Text = null; + _passwordConfirmationInput.Text = null; + base.Show(); + } + + private void TrySendRequest() + { + if (Globals.WaitingOnServer) + { + return; + } + + if (!Networking.Network.IsConnected) + { + Interface.ShowAlert(Strings.Errors.NotConnected, alertType: AlertType.Error); + return; + } + + var identifier = Target?.Trim(); + if (string.IsNullOrWhiteSpace(identifier)) + { + Interface.ShowAlert(Strings.Errors.InvalidStateReturnToMainMenu, alertType: AlertType.Error); + ReturnToMainMenu(); + return; + } + + var token = _tokenInput.Text?.Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + var message = _changeMode switch + { + PasswordChangeMode.ResetToken => Strings.PasswordChange.ErrorNoResetCode, + PasswordChangeMode.ExistingPassword => Strings.PasswordChange.ErrorNoOldPassword, + _ => throw Exceptions.UnreachableInvalidEnum(_changeMode), + }; + Interface.ShowAlert(message, alertType: AlertType.Error); + return; + } + + var password = _passwordInput.Text?.Trim(); + if (string.IsNullOrWhiteSpace(password)) + { + Interface.ShowAlert(Strings.PasswordChange.ErrorNoNewPassword, alertType: AlertType.Error); + return; + } + + var passwordConfirmation = _passwordConfirmationInput.Text?.Trim(); + if (string.IsNullOrWhiteSpace(passwordConfirmation)) + { + Interface.ShowAlert(Strings.PasswordChange.ErrorNoNewPasswordConfirmation, alertType: AlertType.Error); + return; + } + + if (!FieldChecking.IsValidPassword(password, Strings.Regex.Password)) + { + Interface.ShowAlert(Strings.Errors.PasswordInvalid, alertType: AlertType.Error); + return; + } + + if (!string.Equals(password, passwordConfirmation, StringComparison.Ordinal)) + { + Interface.ShowAlert(Strings.Registration.PasswordMismatch, alertType: AlertType.Error); + return; + } + + if (_changeMode == PasswordChangeMode.ExistingPassword) + { + token = PasswordUtils.ComputePasswordHash(token); + } + + var passwordHash = PasswordUtils.ComputePasswordHash(password); + + PacketSender.SendPasswordChangeRequest(identifier, token, passwordHash); + + Globals.WaitingOnServer = true; + ChatboxMsg.ClearMessages(); + } + + protected override void EnsureInitialized() + { + LoadJsonUi(GameContentManager.UI.Menu, Graphics.Renderer.GetResolutionString()); + } +} diff --git a/Intersect.Client.Core/Interface/Menu/RegistrationWindow.cs b/Intersect.Client.Core/Interface/Menu/RegistrationWindow.cs index 6cedb5de16..6f126a8b94 100644 --- a/Intersect.Client.Core/Interface/Menu/RegistrationWindow.cs +++ b/Intersect.Client.Core/Interface/Menu/RegistrationWindow.cs @@ -332,35 +332,35 @@ private void TryRegister() return; } - if (!FieldChecking.IsValidUsername(_usernameInput.Text, Strings.Regex.Username)) + var username = _usernameInput.Text?.Trim(); + if (!FieldChecking.IsValidUsername(username, Strings.Regex.Username)) { Interface.ShowAlert(Strings.Errors.UsernameInvalid, alertType: AlertType.Error); return; } - if (!FieldChecking.IsWellformedEmailAddress(_emailInput.Text, Strings.Regex.Email)) + var email = _emailInput.Text?.Trim(); + if (!FieldChecking.IsWellformedEmailAddress(email, Strings.Regex.Email)) { Interface.ShowAlert(Strings.Registration.EmailInvalid, alertType: AlertType.Error); return; } - if (!FieldChecking.IsValidPassword(_passwordInput.Text, Strings.Regex.Password)) + var password = _passwordInput.Text?.Trim(); + if (!FieldChecking.IsValidPassword(password, Strings.Regex.Password)) { Interface.ShowAlert(Strings.Errors.PasswordInvalid, alertType: AlertType.Error); return; } - if (_passwordInput.Text != _passwordConfirmationInput.Text) + if (password != _passwordConfirmationInput.Text) { Interface.ShowAlert(Strings.Registration.PasswordMismatch, alertType: AlertType.Error); return; } - PacketSender.SendCreateAccount( - _usernameInput.Text, - PasswordUtils.ComputePasswordHash(_passwordInput.Text.Trim()), - _emailInput.Text - ); + var passwordHash = PasswordUtils.ComputePasswordHash(password); + PacketSender.SendUserRegistration(username, passwordHash, email); Globals.WaitingOnServer = true; _registerButton.Disable(); diff --git a/Intersect.Client.Core/Interface/Menu/ResetPasswordWindow.cs b/Intersect.Client.Core/Interface/Menu/ResetPasswordWindow.cs deleted file mode 100644 index a7afdb4737..0000000000 --- a/Intersect.Client.Core/Interface/Menu/ResetPasswordWindow.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Intersect.Client.Core; -using Intersect.Client.Framework.File_Management; -using Intersect.Client.Framework.Gwen.Control; -using Intersect.Client.Framework.Gwen.Control.EventArguments; -using Intersect.Client.Framework.Input; -using Intersect.Client.General; -using Intersect.Client.Interface.Game.Chat; -using Intersect.Client.Interface.Shared; -using Intersect.Client.Localization; -using Intersect.Client.Networking; -using Intersect.Utilities; - -namespace Intersect.Client.Interface.Menu; - - -public partial class ResetPasswordWindow -{ - - private Button mBackBtn; - - //Reset Code - private ImagePanel mCodeInputBackground; - - private Label mCodeInputLabel; - - private TextBox mCodeInputTextbox; - - //Parent - private MainMenu mMainMenu; - - //Password Fields - private ImagePanel mPasswordBackground; - - private ImagePanel mPasswordBackground2; - - private Label mPasswordLabel; - - private Label mPasswordLabel2; - - private TextBoxPassword mPasswordTextbox; - - private TextBoxPassword mPasswordTextbox2; - - //Controls - private ImagePanel mResetWindow; - - private Button mSubmitBtn; - - private Label mWindowHeader; - - //Init - public ResetPasswordWindow(Canvas parent, MainMenu mainMenu) - { - //Assign References - mMainMenu = mainMenu; - - //Main Menu Window - mResetWindow = new ImagePanel(parent, "ResetPasswordWindow"); - mResetWindow.IsHidden = true; - - //Menu Header - mWindowHeader = new Label(mResetWindow, "Header"); - mWindowHeader.SetText(Strings.ResetPass.Title); - - //Code Fields/Labels - mCodeInputBackground = new ImagePanel(mResetWindow, "CodePanel"); - - mCodeInputLabel = new Label(mCodeInputBackground, "CodeLabel"); - mCodeInputLabel.SetText(Strings.ResetPass.Code); - - mCodeInputTextbox = new TextBox(mCodeInputBackground, "CodeField") - { - IsTabable = true, - }; - mCodeInputTextbox.SubmitPressed += Textbox_SubmitPressed; - mCodeInputTextbox.Clicked += Textbox_Clicked; - - //Password Fields/Labels - //Register Password Background - mPasswordBackground = new ImagePanel(mResetWindow, "Password1Panel"); - - mPasswordLabel = new Label(mPasswordBackground, "Password1Label"); - mPasswordLabel.SetText(Strings.ResetPass.NewPassword); - - mPasswordTextbox = new TextBoxPassword(mPasswordBackground, "Password1Field") - { - IsTabable = true, - }; - mPasswordTextbox.SubmitPressed += PasswordTextbox_SubmitPressed; - - //Confirm Password Fields/Labels - mPasswordBackground2 = new ImagePanel(mResetWindow, "Password2Panel"); - - mPasswordLabel2 = new Label(mPasswordBackground2, "Password2Label"); - mPasswordLabel2.SetText(Strings.ResetPass.ConfirmPassword); - - mPasswordTextbox2 = new TextBoxPassword(mPasswordBackground2, "Password2Field") - { - IsTabable = true, - }; - mPasswordTextbox2.SubmitPressed += PasswordTextbox2_SubmitPressed; - - //Login - Send Login Button - mSubmitBtn = new Button(mResetWindow, "SubmitButton") - { - IsTabable = true, - }; - mSubmitBtn.SetText(Strings.ResetPass.Submit); - mSubmitBtn.Clicked += SubmitBtn_Clicked; - - //Login - Back Button - mBackBtn = new Button(mResetWindow, "BackButton") - { - IsTabable = true, - }; - mBackBtn.SetText(Strings.ResetPass.Back); - mBackBtn.Clicked += BackBtn_Clicked; - - mResetWindow.LoadJsonUi(GameContentManager.UI.Menu, Graphics.Renderer.GetResolutionString()); - } - - public bool IsHidden => mResetWindow.IsHidden; - - //The username or email of the acc we are resetting the pass for - public string Target { set; get; } = string.Empty; - - private void Textbox_Clicked(Base sender, MouseButtonState arguments) - { - Globals.InputManager.OpenKeyboard( - KeyboardType.Normal, mCodeInputTextbox.Text, false, false, false - ); - } - - //Methods - public void Update() - { - // ReSharper disable once InvertIf - if (!Networking.Network.IsConnected) - { - Hide(); - mMainMenu.Show(); - } - } - - public void Hide() - { - mResetWindow.IsHidden = true; - } - - public void Show() - { - mResetWindow.IsHidden = false; - mCodeInputTextbox.Text = string.Empty; - mPasswordTextbox.Text = string.Empty; - mPasswordTextbox2.Text = string.Empty; - } - - void BackBtn_Clicked(Base sender, MouseButtonState arguments) - { - Hide(); - Interface.MenuUi.MainMenu.NotifyOpenLogin(); - } - - void Textbox_SubmitPressed(Base sender, EventArgs arguments) - { - TrySendCode(); - } - - void SubmitBtn_Clicked(Base sender, MouseButtonState arguments) - { - TrySendCode(); - } - - void PasswordTextbox_SubmitPressed(Base sender, EventArgs arguments) - { - TrySendCode(); - } - - void PasswordTextbox2_SubmitPressed(Base sender, EventArgs arguments) - { - TrySendCode(); - } - - public void TrySendCode() - { - if (Globals.WaitingOnServer) - { - return; - } - - if (!Networking.Network.IsConnected) - { - Interface.ShowAlert(Strings.Errors.NotConnected, alertType: AlertType.Error); - return; - } - - if (string.IsNullOrEmpty(mCodeInputTextbox?.Text)) - { - Interface.ShowAlert(Strings.ResetPass.InputCode, alertType: AlertType.Error); - return; - } - - if (mPasswordTextbox.Text != mPasswordTextbox2.Text) - { - Interface.ShowAlert(Strings.Registration.PasswordMismatch, alertType: AlertType.Error); - return; - } - - if (!FieldChecking.IsValidPassword(mPasswordTextbox.Text, Strings.Regex.Password)) - { - Interface.ShowAlert(Strings.Errors.PasswordInvalid, alertType: AlertType.Error); - return; - } - - using (var sha = new SHA256Managed()) - { - PacketSender.SendResetPassword( - Target, mCodeInputTextbox?.Text, - BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(mPasswordTextbox.Text.Trim()))) - .Replace("-", "") - ); - } - - Globals.WaitingOnServer = true; - ChatboxMsg.ClearMessages(); - } - -} diff --git a/Intersect.Client.Core/Interface/Menu/SelectCharacterWindow.cs b/Intersect.Client.Core/Interface/Menu/SelectCharacterWindow.cs index cf22256bc7..1fd3027d39 100644 --- a/Intersect.Client.Core/Interface/Menu/SelectCharacterWindow.cs +++ b/Intersect.Client.Core/Interface/Menu/SelectCharacterWindow.cs @@ -25,6 +25,7 @@ public partial class SelectCharacterWindow : Window private readonly Button _buttonPlay; private readonly Button _buttonDelete; private readonly Button _buttonNew; + private readonly Button _buttonChangePassword; private readonly Button _buttonLogout; private ImagePanel[]? _renderLayers; @@ -66,7 +67,9 @@ public SelectCharacterWindow(Canvas parent, MainMenu mainMenu) : base( _buttonsPanel = new Panel(this, name: nameof(_buttonsPanel)) { + DisplayMode = DisplayMode.FlowStartToEnd, Dock = Pos.Bottom, + DockChildSpacing = new Padding(8), Margin = new Margin(0, 8, 0, 0), ShouldDrawBackground = false, }; @@ -76,7 +79,7 @@ public SelectCharacterWindow(Canvas parent, MainMenu mainMenu) : base( Alignment = [Alignments.Left], Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(140, 24), Text = Strings.CharacterSelection.New, }; _buttonNew.Clicked += _buttonNew_Clicked; @@ -86,7 +89,7 @@ public SelectCharacterWindow(Canvas parent, MainMenu mainMenu) : base( Alignment = [Alignments.Left], Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Text = Strings.CharacterSelection.Play, }; _buttonPlay.Clicked += ButtonPlay_Clicked; @@ -96,17 +99,27 @@ public SelectCharacterWindow(Canvas parent, MainMenu mainMenu) : base( Alignment = [Alignments.CenterH], Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Text = Strings.CharacterSelection.Delete, }; _buttonDelete.Clicked += _buttonDelete_Clicked; + _buttonChangePassword = new Button(_buttonsPanel, name: nameof(_buttonLogout)) + { + Alignment = [Alignments.Right], + Font = _defaultFont, + FontSize = 12, + MinimumSize = new Point(160, 24), + Text = Strings.CharacterSelection.ChangePassword, + }; + _buttonChangePassword.Clicked += ButtonChangePasswordOnClicked; + _buttonLogout = new Button(_buttonsPanel, name: nameof(_buttonLogout)) { Alignment = [Alignments.Right], Font = _defaultFont, FontSize = 12, - MinimumSize = new Point(120, 24), + MinimumSize = new Point(160, 24), Text = Strings.CharacterSelection.Logout, }; _buttonLogout.Clicked += _buttonLogout_Clicked; @@ -174,6 +187,11 @@ public SelectCharacterWindow(Canvas parent, MainMenu mainMenu) : base( _buttonsPanel.SizeToChildren(recursive: true); } + private void ButtonChangePasswordOnClicked(Base sender, MouseButtonState arguments) + { + _mainMenu.OpenPasswordChangeWindow(null, PasswordChangeMode.ExistingPassword, this); + } + protected override void EnsureInitialized() { SizeToChildren(recursive: true); diff --git a/Intersect.Client.Core/Localization/Strings.cs b/Intersect.Client.Core/Localization/Strings.cs index 8bbdeb8838..3f10edd4c8 100644 --- a/Intersect.Client.Core/Localization/Strings.cs +++ b/Intersect.Client.Core/Localization/Strings.cs @@ -6,6 +6,7 @@ using Intersect.Configuration; using Intersect.Core; using Intersect.Enums; +using Intersect.Framework.Core.Security; using Intersect.Framework.Reflection; using Intersect.Localization; using Microsoft.Extensions.Logging; @@ -731,6 +732,9 @@ public partial struct CharacterSelection [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Logout = @"Logout"; + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString ChangePassword = @"Change Password"; + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Name = @"{00}"; @@ -1145,6 +1149,9 @@ public partial struct Errors [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString NotConnected = @"Not connected to the game server. Is it online?"; + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString InvalidStateReturnToMainMenu = @"An invalid state was detected and we had to return to the main menu."; + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString OpenAllLink = @"https://goo.gl/Nbx6hx"; @@ -1187,10 +1194,10 @@ public partial struct ForgotPass public static LocalizedString Back = @"Back"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString Hint = @"If your account exists, we will send you a temporary password reset code."; + public static LocalizedString Disclaimer = @"If an account exists with the provided username or email address, an email with a password reset code will be sent to the email address associated with the account."; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString Label = @"Enter your username or email below:"; + public static LocalizedString UsernameOrEmailPlaceholder = @"Username or Email Address"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Submit = @"Submit"; @@ -2393,38 +2400,66 @@ public partial struct Registration public static LocalizedString Username = @"Username:"; } - public partial struct ResetPass + public partial struct PasswordChange { [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Back = @"Cancel"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString Code = @"Enter the reset code that was sent to you:"; + public static LocalizedString ConfirmNewPassword = @"Confirm New Password"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString ConfirmPassword = @"Confirm Password:"; + public static LocalizedString ErrorNoResetCode = @"Please enter your reset code"; + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString ErrorNoOldPassword = @"Please enter your previous password"; + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString ErrorNoNewPassword = @"Please enter your new password"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString Error = @"Error!"; + public static LocalizedString ErrorNoNewPasswordConfirmation = @"Please confirm your new password"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString ErrorMessage = - @"The reset code was not valid, has expired, or the account does not exist!"; + public static LocalizedString AlertTitleError = @"Error!"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString InputCode = @"Please enter your password reset code."; + public static LocalizedString InputLabelResetCode = @"Reset code"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString NewPassword = @"New Password:"; + public static LocalizedString InputLabelPassword = @"Old Password"; + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public static LocalizedString NewPassword = @"New Password"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Submit = @"Submit"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString Success = @"Success!"; + public static LocalizedString AlertTitleSuccess = @"Success!"; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - public static LocalizedString SuccessMessage = @"Your password has been reset!"; + public static Dictionary ResponseMessages = new() + { + { + PasswordResetResultType.InvalidRequest, + @"Unable to change the password because the request was invalid." + }, + { + PasswordResetResultType.InvalidToken, + @"Unable to change the password because the reset token or password was invalid or expired." + }, + { + PasswordResetResultType.NoUserFound, + @"Unable to change the password because the user could not be found." + }, + { + PasswordResetResultType.Success, @"Successfully changed the password." + }, + { + PasswordResetResultType.Unknown, @"An unknown error occurred while changing the password." + }, + }; [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public static LocalizedString Title = @"Password Reset"; diff --git a/Intersect.Client.Core/Networking/PacketHandler.cs b/Intersect.Client.Core/Networking/PacketHandler.cs index b50d366105..bbf3ffad8a 100644 --- a/Intersect.Client.Core/Networking/PacketHandler.cs +++ b/Intersect.Client.Core/Networking/PacketHandler.cs @@ -30,6 +30,7 @@ using Intersect.Framework.Core.GameObjects.Maps.Attributes; using Intersect.Framework.Core.GameObjects.Maps.MapList; using Intersect.Framework.Core.Security; +using Intersect.Localization; using Microsoft.Extensions.Logging; namespace Intersect.Client.Networking; @@ -2203,7 +2204,7 @@ public void HandlePacket(IPacketSender packetSender, CharactersPacket packet) characterPacket.ClassName, characterPacket.Equipment ) - ) + ), ]; if (packet.FreeSlot) @@ -2212,30 +2213,30 @@ public void HandlePacket(IPacketSender packetSender, CharactersPacket packet) } Globals.WaitingOnServer = false; - Interface.Interface.MenuUi.MainMenu.NotifyOpenCharacterSelection(characterSelectionPreviews); + Interface.Interface.MenuUi.MainMenu.NotifyOpenCharacterSelection(characterSelectionPreviews, packet.Username); } //PasswordResetResultPacket - public void HandlePacket(IPacketSender packetSender, PasswordResetResultPacket packet) - { - if (packet.Succeeded) + public void HandlePacket(IPacketSender packetSender, PasswordChangeResultPacket packet) + { + var passwordResetResultType = packet.ResultType; + var responseMessage = Strings.PasswordChange.ResponseMessages[passwordResetResultType]; + var requestSuccessful = passwordResetResultType == PasswordResetResultType.Success; + var alertType = requestSuccessful + ? AlertType.Information + : AlertType.Error; + var alertTitle = requestSuccessful ? Strings.PasswordChange.AlertTitleSuccess : Strings.PasswordChange.AlertTitleError; + + Interface.Interface.ShowAlert( + message: responseMessage, + title: alertTitle, + alertType: alertType + ); + + if (requestSuccessful) { - // Show Success Message and Open Login Screen - Interface.Interface.ShowAlert( - Strings.ResetPass.Success, - Strings.ResetPass.SuccessMessage, - AlertType.Information - ); Interface.Interface.MenuUi.MainMenu.NotifyOpenLogin(); } - else - { - Interface.Interface.ShowAlert( - Strings.ResetPass.Error, - Strings.ResetPass.ErrorMessage, - alertType: AlertType.Error - ); - } Globals.WaitingOnServer = false; } diff --git a/Intersect.Client.Core/Networking/PacketSender.cs b/Intersect.Client.Core/Networking/PacketSender.cs index b9693496a1..b2898b04f9 100644 --- a/Intersect.Client.Core/Networking/PacketSender.cs +++ b/Intersect.Client.Core/Networking/PacketSender.cs @@ -177,9 +177,9 @@ public static void SendEventInputVariableCancel(object? sender, EventArgs e) Network.SendPacket(new EventInputVariablePacket(eventId, default, default, default, true)); } - public static void SendCreateAccount(string username, string password, string email) + public static void SendUserRegistration(string username, string password, string email) { - Network.SendPacket(new CreateAccountPacket(username.Trim(), password.Trim(), email.Trim())); + Network.SendPacket(new UserRegistrationRequestPacket(username, password, email)); } public static void SendCreateCharacter(string name, Guid classId, int sprite) @@ -457,9 +457,9 @@ public static void SendRequestPasswordReset(string nameEmail) Network.SendPacket(new RequestPasswordResetPacket(nameEmail)); } - public static void SendResetPassword(string nameEmail, string code, string hashedPass) + public static void SendPasswordChangeRequest(string identifier, string token, string passwordHash) { - Network.SendPacket(new ResetPasswordPacket(nameEmail, code, hashedPass)); + Network.SendPacket(new PasswordChangeRequestPacket(identifier, token, passwordHash)); } public static void SendAdminAction(AdminAction action) diff --git a/Intersect.Client.Framework/Gwen/Control/Base.cs b/Intersect.Client.Framework/Gwen/Control/Base.cs index 5b76238300..65de65f478 100644 --- a/Intersect.Client.Framework/Gwen/Control/Base.cs +++ b/Intersect.Client.Framework/Gwen/Control/Base.cs @@ -1,5 +1,5 @@ -using System.Collections.Concurrent; using System.Diagnostics; +using System.Numerics; using System.Text; using Intersect.Client.Framework.File_Management; using Intersect.Client.Framework.GenericClasses; @@ -10,8 +10,6 @@ using Intersect.Client.Framework.Gwen.ControlInternal; using Intersect.Client.Framework.Gwen.DragDrop; using Intersect.Client.Framework.Gwen.Input; -#if DEBUG || DIAGNOSTIC -#endif using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Intersect.Client.Framework.Gwen.Renderer; @@ -26,8 +24,6 @@ namespace Intersect.Client.Framework.Gwen.Control; -public record struct NodePair(Base This, Base? Parent); - /// /// Base control class. /// @@ -187,6 +183,12 @@ public Base(Base? parent = default, string? name = default) PaddingOutlineColor = Color.Blue; } + public DisplayMode DisplayMode + { + get => _displayMode; + set => SetAndDoIfChanged(ref _displayMode, value, InvalidateDock); + } + private void OnThreadQueueOnQueueNotEmpty() { UpdatePendingThreadQueues(1); @@ -1017,6 +1019,23 @@ public bool IsTabable set => _tabEnabled = value; } + public int TabOrder + { + get => _tabOrder; + set => SetAndDoIfChanged(ref _tabOrder, value, OnTabOrderChanged); + } + + private void OnTabOrderChanged(int oldTabOrder, int newTabOrder) + { + IsTabable = newTabOrder > 0; + Parent?.OnChildTabOrderChanged(this, oldTabOrder, newTabOrder); + } + + protected virtual void OnChildTabOrderChanged(Base? childNode, int oldTabOrder, int newTabOrder) + { + + } + /// /// Indicates whether control's background should be drawn during rendering. /// @@ -1269,6 +1288,8 @@ private static void PropagateDrawDebugOutlinesToChildren(Base @this, bool drawDe public bool SkipSerialization { get; set; } = false; + public event GwenEventHandler? Disposed; + /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// @@ -1333,6 +1354,20 @@ public void Dispose() GC.SuppressFinalize(this); _disposeCompleted = true; + + try + { + Disposed?.Invoke(this, EventArgs.Empty); + } + catch (Exception exception) + { + var logger = ApplicationContext.GetContext()?.Logger; + logger?.LogWarning( + exception, + "Exception thrown by a Disposed event handler for '{Node}'", + CanonicalName + ); + } } protected virtual void Dispose(bool disposing) @@ -2033,6 +2068,8 @@ public virtual void Invalidate() } } + protected static void InvalidateDock(Base @this) => @this.InvalidateDock(); + protected static void Invalidate(Base @this) => @this.Invalidate(); public void Invalidate(bool alsoInvalidateParent) @@ -2844,6 +2881,7 @@ public virtual bool SetBounds(int x, int y, int width, int height) Redraw(); } + InvalidateDock(); OnSizeChanged(oldBounds.Size, newBounds.Size); SizeChanged?.Invoke( this, @@ -3790,6 +3828,7 @@ protected void RecurseLayout(Skin.Base skin) { _dockDirty = false; + var dockChildSpacing = DockChildSpacing; var remainingBounds = RenderBounds; // Adjust bounds for padding @@ -3798,333 +3837,447 @@ protected void RecurseLayout(Skin.Base skin) remainingBounds.Y += _padding.Top; remainingBounds.Height -= _padding.Top + _padding.Bottom; - var dockChildSpacing = DockChildSpacing; - - var directionalDockChildren = - _children.Where(child => !child.ShouldSkipLayout && !child.Dock.HasFlag(Pos.Fill)).ToArray(); - var dockFillChildren = - _children.Where(child => !child.ShouldSkipLayout && child.Dock.HasFlag(Pos.Fill)).ToArray(); - - foreach (var child in directionalDockChildren) + var displayMode = _displayMode; + var isFlow = displayMode switch { - var childDock = child.Dock; - - var childMargin = child.Margin; - var childMarginH = childMargin.Left + childMargin.Right; - var childMarginV = childMargin.Top + childMargin.Bottom; - - var childOuterWidth = childMarginH + child.Width; - var childOuterHeight = childMarginV + child.Height; + DisplayMode.Initial => false, + DisplayMode.FlowStartToEnd => true, + DisplayMode.FlowTopToBottom => true, + _ => throw Exceptions.UnreachableInvalidEnum(displayMode), + }; - var availableWidth = remainingBounds.Width - childMarginH; - var availableHeight = remainingBounds.Height - childMarginV; + if (isFlow) + { + Point cursor = remainingBounds.Position; + var visibleChildren = _children.Where(child => child.IsVisibleInParent).ToArray(); + var minimumRequiredSize = visibleChildren.Aggregate( + default(Point), + (sum, node) => + { + switch (displayMode) + { + case DisplayMode.FlowStartToEnd: + { + sum.X += node.MinimumSize.X; + sum.Y = Math.Max(sum.Y, node.MinimumSize.Y); + break; + } + + case DisplayMode.FlowTopToBottom: + { + sum.X = Math.Max(sum.X, node.MinimumSize.X); + sum.Y += node.MinimumSize.Y; + break; + } + + default: + throw Exceptions.UnreachableInvalidEnum(displayMode); + } - var childFitsContentWidth = false; - var childFitsContentHeight = false; + return sum; + } + ); - switch (child) + var gapCountX = 0; + var gapCountY = 0; + switch (displayMode) { - case ISmartAutoSizeToContents smartAutoSizeToContents: - childFitsContentWidth = smartAutoSizeToContents.AutoSizeToContentWidth; - childFitsContentHeight = smartAutoSizeToContents.AutoSizeToContentHeight; - break; - case IAutoSizeToContents { AutoSizeToContents: true }: - childFitsContentWidth = true; - childFitsContentHeight = true; + case DisplayMode.FlowStartToEnd: + { + gapCountX = visibleChildren.Length - 1; break; - case IFitHeightToContents { FitHeightToContents: true }: - childFitsContentHeight = true; + } + + case DisplayMode.FlowTopToBottom: + { + gapCountY = visibleChildren.Length - 1; break; + } + + default: + throw Exceptions.UnreachableInvalidEnum(displayMode); } - if (childDock.HasFlag(Pos.Left)) + minimumRequiredSize.X += DockChildSpacing.Left * gapCountX; + minimumRequiredSize.Y += DockChildSpacing.Top * gapCountY; + + var containingSize = remainingBounds.Size; + if (containingSize.X < minimumRequiredSize.X || containingSize.Y < minimumRequiredSize.Y) { - var height = childFitsContentHeight - ? child.Height - : availableHeight; + _requiredSizeForDockFillNodes = minimumRequiredSize; + } - var y = remainingBounds.Y + childMargin.Top; - if (childDock.HasFlag(Pos.CenterV)) + var totalSize = visibleChildren.Aggregate(default(Point), (sum, node) => sum + node.OuterBounds.Size); + var spaceBetween = visibleChildren.Length > 1 + ? (containingSize - totalSize) / (visibleChildren.Length - 1) + : default; + switch (displayMode) + { + case DisplayMode.FlowStartToEnd: { - height = child.Height; - var extraY = Math.Max(0, availableHeight - height) / 2; - if (extraY != 0) + var remainingPixels = containingSize.X % visibleChildren.Length; + foreach (var child in visibleChildren) { - y += extraY; + var extraPixel = remainingPixels-- > 0 ? 1 : 0; + var cursorDelta = extraPixel + child.Width + spaceBetween.X; + var x = cursor.X + extraPixel; + var y = cursor.Y + (remainingBounds.Height - child.Height) / 2; + child.SetPosition(x, y); + + cursor.X += cursorDelta; } + break; } - else if (childDock.HasFlag(Pos.Bottom)) - { - y = remainingBounds.Bottom - (childMargin.Bottom + child.Height); - } - else if (!childDock.HasFlag(Pos.Top)) + + case DisplayMode.FlowTopToBottom: { - var extraY = Math.Max(0, availableHeight - height) / 2; - if (extraY != 0) + var remainingPixels = containingSize.Y % visibleChildren.Length; + foreach (var child in visibleChildren) { - y += extraY; + var extraPixel = remainingPixels-- > 0 ? 1 : 0; + var cursorDelta = extraPixel + child.Height + spaceBetween.Y; + var x = cursor.X + (remainingBounds.Width - child.Width) / 2; + var y = cursor.Y + extraPixel; + child.SetPosition(x, y); + + cursor.X += cursorDelta; } + break; } - child.SetBounds( - remainingBounds.X + childMargin.Left, - y, - child.Width, - height - ); - - var boundsDeltaX = childOuterWidth + dockChildSpacing.Left; - remainingBounds.X += boundsDeltaX; - remainingBounds.Width -= boundsDeltaX; + default: + throw Exceptions.UnreachableInvalidEnum(displayMode); } + } + else + { + var directionalDockChildren = + _children.Where(child => !child.ShouldSkipLayout && !child.Dock.HasFlag(Pos.Fill)).ToArray(); + var dockFillChildren = + _children.Where(child => !child.ShouldSkipLayout && child.Dock.HasFlag(Pos.Fill)).ToArray(); - if (childDock.HasFlag(Pos.Right)) + foreach (var child in directionalDockChildren) { - var height = childFitsContentHeight - ? child.Height - : availableHeight; + var childDock = child.Dock; + + var childMargin = child.Margin; + var childMarginH = childMargin.Left + childMargin.Right; + var childMarginV = childMargin.Top + childMargin.Bottom; + + var childOuterWidth = childMarginH + child.Width; + var childOuterHeight = childMarginV + child.Height; - var y = remainingBounds.Y + childMargin.Top; - if (childDock.HasFlag(Pos.CenterV)) + var availableWidth = remainingBounds.Width - childMarginH; + var availableHeight = remainingBounds.Height - childMarginV; + + var childFitsContentWidth = false; + var childFitsContentHeight = false; + + switch (child) { - height = child.Height; - var extraY = Math.Max(0, availableHeight - height) / 2; - if (extraY != 0) - { - y += extraY; - } + case ISmartAutoSizeToContents smartAutoSizeToContents: + childFitsContentWidth = smartAutoSizeToContents.AutoSizeToContentWidth; + childFitsContentHeight = smartAutoSizeToContents.AutoSizeToContentHeight; + break; + case IAutoSizeToContents { AutoSizeToContents: true }: + childFitsContentWidth = true; + childFitsContentHeight = true; + break; + case IFitHeightToContents { FitHeightToContents: true }: + childFitsContentHeight = true; + break; } - else if (childDock.HasFlag(Pos.Bottom)) + + if (childDock.HasFlag(Pos.Left)) { - y = remainingBounds.Bottom - (childMargin.Bottom + child.Height); + var height = childFitsContentHeight + ? child.Height + : availableHeight; + + var y = remainingBounds.Y + childMargin.Top; + if (childDock.HasFlag(Pos.CenterV)) + { + height = child.Height; + var extraY = Math.Max(0, availableHeight - height) / 2; + if (extraY != 0) + { + y += extraY; + } + } + else if (childDock.HasFlag(Pos.Bottom)) + { + y = remainingBounds.Bottom - (childMargin.Bottom + child.Height); + } + else if (!childDock.HasFlag(Pos.Top)) + { + var extraY = Math.Max(0, availableHeight - height) / 2; + if (extraY != 0) + { + y += extraY; + } + } + + child.SetBounds( + remainingBounds.X + childMargin.Left, + y, + child.Width, + height + ); + + var boundsDeltaX = childOuterWidth + dockChildSpacing.Left; + remainingBounds.X += boundsDeltaX; + remainingBounds.Width -= boundsDeltaX; } - else if (!childDock.HasFlag(Pos.Top)) + + if (childDock.HasFlag(Pos.Right)) { - var extraY = Math.Max(0, availableHeight - height) / 2; - if (extraY != 0) + var height = childFitsContentHeight + ? child.Height + : availableHeight; + + var y = remainingBounds.Y + childMargin.Top; + if (childDock.HasFlag(Pos.CenterV)) { - y += extraY; + height = child.Height; + var extraY = Math.Max(0, availableHeight - height) / 2; + if (extraY != 0) + { + y += extraY; + } } + else if (childDock.HasFlag(Pos.Bottom)) + { + y = remainingBounds.Bottom - (childMargin.Bottom + child.Height); + } + else if (!childDock.HasFlag(Pos.Top)) + { + var extraY = Math.Max(0, availableHeight - height) / 2; + if (extraY != 0) + { + y += extraY; + } + } + + var offsetFromRight = child.Width + childMargin.Right; + child.SetBounds( + remainingBounds.X + remainingBounds.Width - offsetFromRight, + y, + child.Width, + height + ); + + var boundsDeltaX = childOuterWidth + dockChildSpacing.Right; + remainingBounds.Width -= boundsDeltaX; } - var offsetFromRight = child.Width + childMargin.Right; - child.SetBounds( - remainingBounds.X + remainingBounds.Width - offsetFromRight, - y, - child.Width, - height - ); + if (childDock.HasFlag(Pos.Top) && !childDock.HasFlag(Pos.Left) && !childDock.HasFlag(Pos.Right)) + { + var width = childFitsContentWidth + ? child.Width + : availableWidth; - var boundsDeltaX = childOuterWidth + dockChildSpacing.Right; - remainingBounds.Width -= boundsDeltaX; - } + var x = remainingBounds.Left + childMargin.Left; - if (childDock.HasFlag(Pos.Top) && !childDock.HasFlag(Pos.Left) && !childDock.HasFlag(Pos.Right)) - { - var width = childFitsContentWidth - ? child.Width - : availableWidth; + if (childDock.HasFlag(Pos.CenterH)) + { + x = remainingBounds.Left + (remainingBounds.Width - child.OuterWidth) / 2; + width = child.Width; + } - var x = remainingBounds.Left + childMargin.Left; + child.SetBounds( + x, + remainingBounds.Top + childMargin.Top, + width, + child.Height + ); - if (childDock.HasFlag(Pos.CenterH)) - { - x = remainingBounds.Left + (remainingBounds.Width - child.OuterWidth) / 2; - width = child.Width; + var boundsDeltaY = childOuterHeight + dockChildSpacing.Top; + remainingBounds.Y += boundsDeltaY; + remainingBounds.Height -= boundsDeltaY; } - child.SetBounds( - x, - remainingBounds.Top + childMargin.Top, - width, - child.Height - ); + if (childDock.HasFlag(Pos.Bottom) && !childDock.HasFlag(Pos.Left) && !childDock.HasFlag(Pos.Right)) + { + var width = childFitsContentWidth + ? child.Width + : availableWidth; - var boundsDeltaY = childOuterHeight + dockChildSpacing.Top; - remainingBounds.Y += boundsDeltaY; - remainingBounds.Height -= boundsDeltaY; - } + var offsetFromBottom = child.Height + childMargin.Bottom; + var x = remainingBounds.Left + childMargin.Left; - if (childDock.HasFlag(Pos.Bottom) && !childDock.HasFlag(Pos.Left) && !childDock.HasFlag(Pos.Right)) - { - var width = childFitsContentWidth - ? child.Width - : availableWidth; + if (childDock.HasFlag(Pos.CenterH)) + { + x = remainingBounds.Left + (remainingBounds.Width - child.OuterWidth) / 2; + width = child.Width; + } - var offsetFromBottom = child.Height + childMargin.Bottom; - var x = remainingBounds.Left + childMargin.Left; + child.SetBounds( + x, + remainingBounds.Bottom - offsetFromBottom, + width, + child.Height + ); - if (childDock.HasFlag(Pos.CenterH)) - { - x = remainingBounds.Left + (remainingBounds.Width - child.OuterWidth) / 2; - width = child.Width; + remainingBounds.Height -= childOuterHeight + dockChildSpacing.Bottom; } - child.SetBounds( - x, - remainingBounds.Bottom - offsetFromBottom, - width, - child.Height - ); - - remainingBounds.Height -= childOuterHeight + dockChildSpacing.Bottom; + child.RecurseLayout(skin); } - child.RecurseLayout(skin); - } - - var boundsForFillNodes = remainingBounds; - _innerBounds = remainingBounds; + var boundsForFillNodes = remainingBounds; + _innerBounds = remainingBounds; - Point sizeToFitDockFillNodes = default; + Point sizeToFitDockFillNodes = default; - var largestDockFillSize = dockFillChildren.Aggregate( - default(Point), - (size, node) => - new Point(Math.Max(size.X, node.Width), Math.Max(size.Y, node.Height)) - ); + var largestDockFillSize = dockFillChildren.Aggregate( + default(Point), + (size, node) => + new Point(Math.Max(size.X, node.Width), Math.Max(size.Y, node.Height)) + ); - int suggestedWidth, suggestedHeight; - if (dockFillChildren.Length < 2) - { - suggestedWidth = remainingBounds.Width; - suggestedHeight = remainingBounds.Height; - } - else if (largestDockFillSize.Y > largestDockFillSize.X) - { - if (largestDockFillSize.Y > remainingBounds.Height) + int suggestedWidth, suggestedHeight; + if (dockFillChildren.Length < 2) { - suggestedWidth = Math.Max(largestDockFillSize.X, remainingBounds.Width); - suggestedHeight = remainingBounds.Height / dockFillChildren.Length; + suggestedWidth = remainingBounds.Width; + suggestedHeight = remainingBounds.Height; } - else + else if (largestDockFillSize.Y > largestDockFillSize.X) + { + if (largestDockFillSize.Y > remainingBounds.Height) + { + suggestedWidth = Math.Max(largestDockFillSize.X, remainingBounds.Width); + suggestedHeight = remainingBounds.Height / dockFillChildren.Length; + } + else + { + suggestedWidth = remainingBounds.Width / dockFillChildren.Length; + suggestedHeight = Math.Max(largestDockFillSize.Y, remainingBounds.Height); + } + } + else if (largestDockFillSize.X > remainingBounds.Width) { suggestedWidth = remainingBounds.Width / dockFillChildren.Length; suggestedHeight = Math.Max(largestDockFillSize.Y, remainingBounds.Height); } - } - else if (largestDockFillSize.X > remainingBounds.Width) - { - suggestedWidth = remainingBounds.Width / dockFillChildren.Length; - suggestedHeight = Math.Max(largestDockFillSize.Y, remainingBounds.Height); - } - else - { - suggestedWidth = Math.Max(largestDockFillSize.X, remainingBounds.Width); - suggestedHeight = remainingBounds.Height / dockFillChildren.Length; - } - - var fillHorizontal = suggestedHeight == remainingBounds.Height; - - // - // Fill uses the left over space, so do that now. - // - foreach (var child in dockFillChildren) - { - var dock = child.Dock; + else + { + suggestedWidth = Math.Max(largestDockFillSize.X, remainingBounds.Width); + suggestedHeight = remainingBounds.Height / dockFillChildren.Length; + } - var childMargin = child.Margin; - var childMarginH = childMargin.Left + childMargin.Right; - var childMarginV = childMargin.Top + childMargin.Bottom; + var fillHorizontal = suggestedHeight == remainingBounds.Height; - Point newPosition = new( - remainingBounds.X + childMargin.Left, - remainingBounds.Y + childMargin.Top - ); + // + // Fill uses the left over space, so do that now. + // + foreach (var child in dockFillChildren) + { + var dock = child.Dock; - Point newSize = new( - suggestedWidth - childMarginH, - suggestedHeight - childMarginV - ); + var childMargin = child.Margin; + var childMarginH = childMargin.Left + childMargin.Right; + var childMarginV = childMargin.Top + childMargin.Bottom; - var childMinimumSize = child.MinimumSize; - var neededX = Math.Max(0, childMinimumSize.X - newSize.X); - var neededY = Math.Max(0, childMinimumSize.Y - newSize.Y); + Point newPosition = new( + remainingBounds.X + childMargin.Left, + remainingBounds.Y + childMargin.Top + ); - bool exhaustSize = false; - if (neededX > 0 || neededY > 0) - { - exhaustSize = true; + Point newSize = new( + suggestedWidth - childMarginH, + suggestedHeight - childMarginV + ); - if (sizeToFitDockFillNodes == default) - { - sizeToFitDockFillNodes = Size; - } + var childMinimumSize = child.MinimumSize; + var neededX = Math.Max(0, childMinimumSize.X - newSize.X); + var neededY = Math.Max(0, childMinimumSize.Y - newSize.Y); - sizeToFitDockFillNodes.X += neededX; - sizeToFitDockFillNodes.Y += neededY; - } - else if (remainingBounds.Width < 1 || remainingBounds.Height < 1) - { - if (sizeToFitDockFillNodes == default) + bool exhaustSize = false; + if (neededX > 0 || neededY > 0) { - sizeToFitDockFillNodes = Size; - } - - sizeToFitDockFillNodes.X += Math.Max(10, boundsForFillNodes.Width / dockFillChildren.Length); - sizeToFitDockFillNodes.Y += Math.Max(10, boundsForFillNodes.Height / dockFillChildren.Length); - } + exhaustSize = true; - newSize.X = Math.Max(childMinimumSize.X, newSize.X); - newSize.Y = Math.Max(childMinimumSize.Y, newSize.Y); + if (sizeToFitDockFillNodes == default) + { + sizeToFitDockFillNodes = Size; + } - if (child is IAutoSizeToContents { AutoSizeToContents: true }) - { - if (Pos.Right == (dock & (Pos.Right | Pos.Left))) - { - var offsetFromRight = child.Width + childMargin.Right + dockChildSpacing.Right; - newPosition.X = remainingBounds.Right - offsetFromRight; + sizeToFitDockFillNodes.X += neededX; + sizeToFitDockFillNodes.Y += neededY; } - - if (Pos.Bottom == (dock & (Pos.Bottom | Pos.Top))) + else if (remainingBounds.Width < 1 || remainingBounds.Height < 1) { - var offsetFromBottom = child.Height + childMargin.Bottom + dockChildSpacing.Bottom; - newPosition.Y = remainingBounds.Bottom - offsetFromBottom; - } + if (sizeToFitDockFillNodes == default) + { + sizeToFitDockFillNodes = Size; + } - if (dock.HasFlag(Pos.CenterH)) - { - newPosition.X = remainingBounds.X + (remainingBounds.Width - (childMarginH + child.Width)) / 2; + sizeToFitDockFillNodes.X += Math.Max(10, boundsForFillNodes.Width / dockFillChildren.Length); + sizeToFitDockFillNodes.Y += Math.Max(10, boundsForFillNodes.Height / dockFillChildren.Length); } - if (dock.HasFlag(Pos.CenterV)) + newSize.X = Math.Max(childMinimumSize.X, newSize.X); + newSize.Y = Math.Max(childMinimumSize.Y, newSize.Y); + + if (child is IAutoSizeToContents { AutoSizeToContents: true }) { - newPosition.Y = remainingBounds.Y + - (remainingBounds.Height - (childMarginV + child.Height)) / 2; - } + if (Pos.Right == (dock & (Pos.Right | Pos.Left))) + { + var offsetFromRight = child.Width + childMargin.Right + dockChildSpacing.Right; + newPosition.X = remainingBounds.Right - offsetFromRight; + } + + if (Pos.Bottom == (dock & (Pos.Bottom | Pos.Top))) + { + var offsetFromBottom = child.Height + childMargin.Bottom + dockChildSpacing.Bottom; + newPosition.Y = remainingBounds.Bottom - offsetFromBottom; + } - child.SetPosition(newPosition); + if (dock.HasFlag(Pos.CenterH)) + { + newPosition.X = remainingBounds.X + + (remainingBounds.Width - (childMarginH + child.Width)) / 2; + } - // TODO: Figure out how to adjust remaining bounds in the autosize case - } - else - { - ApplyDockFill(child, newPosition, newSize); + if (dock.HasFlag(Pos.CenterV)) + { + newPosition.Y = remainingBounds.Y + + (remainingBounds.Height - (childMarginV + child.Height)) / 2; + } - var childOuterBounds = child.OuterBounds; - if (fillHorizontal) - { - var delta = childOuterBounds.Right - remainingBounds.X; - remainingBounds.X = childOuterBounds.Right; - remainingBounds.Width -= delta; + child.SetPosition(newPosition); + + // TODO: Figure out how to adjust remaining bounds in the autosize case } else { - var delta = childOuterBounds.Bottom - remainingBounds.Y; - remainingBounds.Y = childOuterBounds.Bottom; - remainingBounds.Height -= delta; + ApplyDockFill(child, newPosition, newSize); + + var childOuterBounds = child.OuterBounds; + if (fillHorizontal) + { + var delta = childOuterBounds.Right - remainingBounds.X; + remainingBounds.X = childOuterBounds.Right; + remainingBounds.Width -= delta; + } + else + { + var delta = childOuterBounds.Bottom - remainingBounds.Y; + remainingBounds.Y = childOuterBounds.Bottom; + remainingBounds.Height -= delta; + } } - } - if (exhaustSize) - { - remainingBounds.X += remainingBounds.Width; - remainingBounds.Width = 0; - remainingBounds.Y += remainingBounds.Height; - remainingBounds.Height = 0; - } + if (exhaustSize) + { + remainingBounds.X += remainingBounds.Width; + remainingBounds.Width = 0; + remainingBounds.Y += remainingBounds.Height; + remainingBounds.Height = 0; + } - child.RecurseLayout(skin); + child.RecurseLayout(skin); + } } } else @@ -4164,6 +4317,8 @@ protected void RecurseLayout(Skin.Base skin) } protected Point _dockFillSize; + private int _tabOrder; + private DisplayMode _displayMode; protected virtual void ApplyDockFill(Base child, Point position, Point size) { diff --git a/Intersect.Client.Framework/Gwen/Control/Canvas.cs b/Intersect.Client.Framework/Gwen/Control/Canvas.cs index 4993211c88..4237ca0326 100644 --- a/Intersect.Client.Framework/Gwen/Control/Canvas.cs +++ b/Intersect.Client.Framework/Gwen/Control/Canvas.cs @@ -244,7 +244,7 @@ public void AddDelayedDelete(Base control) if (!mDisposeQueue.Contains(control)) { mDisposeQueue.Add(control); - RemoveChild(control, false); + control.Parent?.RemoveChild(control, false); } #if DEBUG else diff --git a/Intersect.Client.Framework/Gwen/Control/DisplayMode.cs b/Intersect.Client.Framework/Gwen/Control/DisplayMode.cs new file mode 100644 index 0000000000..355864cb27 --- /dev/null +++ b/Intersect.Client.Framework/Gwen/Control/DisplayMode.cs @@ -0,0 +1,8 @@ +namespace Intersect.Client.Framework.Gwen.Control; + +public enum DisplayMode +{ + Initial, + FlowStartToEnd, + FlowTopToBottom, +} \ No newline at end of file diff --git a/Intersect.Client.Framework/Gwen/Control/Label.cs b/Intersect.Client.Framework/Gwen/Control/Label.cs index 7237b242aa..778fe86f19 100644 --- a/Intersect.Client.Framework/Gwen/Control/Label.cs +++ b/Intersect.Client.Framework/Gwen/Control/Label.cs @@ -304,7 +304,7 @@ public string? FontName /// /// Font Size /// - public int FontSize + public virtual int FontSize { get => _textElement.FontSize; set => _textElement.FontSize = value; diff --git a/Intersect.Client.Framework/Gwen/Control/NodePair.cs b/Intersect.Client.Framework/Gwen/Control/NodePair.cs new file mode 100644 index 0000000000..e3226a7abd --- /dev/null +++ b/Intersect.Client.Framework/Gwen/Control/NodePair.cs @@ -0,0 +1,3 @@ +namespace Intersect.Client.Framework.Gwen.Control; + +public record struct NodePair(Base This, Base? Parent); \ No newline at end of file diff --git a/Intersect.Client.Framework/Gwen/Control/TextBox.cs b/Intersect.Client.Framework/Gwen/Control/TextBox.cs index 54fe20b803..fb9fb19b2a 100644 --- a/Intersect.Client.Framework/Gwen/Control/TextBox.cs +++ b/Intersect.Client.Framework/Gwen/Control/TextBox.cs @@ -170,6 +170,16 @@ public override IFont? Font } } + public override int FontSize + { + get => base.FontSize; + set + { + base.FontSize = value; + _placeholder.FontSize = value; + } + } + public int MaximumLength { get => mMaxmimumLength; set => mMaxmimumLength = value; } public string? PlaceholderText @@ -405,7 +415,7 @@ protected override void OnCut(Base from, EventArgs args) public void SelectAll() => OnSelectAll(this, EventArgs.Empty); - protected virtual void OnSelectAll(Base from, EventArgs args) + protected override void OnSelectAll(Base from, EventArgs args) { mCursorEnd = 0; mCursorPos = TextLength; diff --git a/Intersect.Server.Core/Database/DbInterface.cs b/Intersect.Server.Core/Database/DbInterface.cs index d2754c9562..e0b7f9570b 100644 --- a/Intersect.Server.Core/Database/DbInterface.cs +++ b/Intersect.Server.Core/Database/DbInterface.cs @@ -414,7 +414,7 @@ internal static bool InitDatabase(IServerContext serverContext) LoadAllGameObjects(); ValidateMapEvents(); - + LoadTime(); OnClassesLoaded(); OnMapsLoaded(); @@ -589,19 +589,12 @@ public static void CreateAccount( client?.SetUser(user); } - public static void ResetPass(User user, string password) + public static void UpdatePassword(User user, string password) { - var sha = new SHA256Managed(); - - //Generate a Salt - var rng = new RNGCryptoServiceProvider(); - var buff = new byte[20]; - rng.GetBytes(buff); - var salt = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(Convert.ToBase64String(buff)))) - .Replace("-", ""); - + var salt = User.GenerateSalt(); + var saltedPasswordHash = User.SaltPasswordHash(password, salt); user.Salt = salt; - user.Password = User.SaltPasswordHash(password, salt); + user.Password = saltedPasswordHash; user.Save(); } @@ -881,12 +874,12 @@ private static void LoadGameObjects(GameObjectType gameObjectType) throw; } } - + private static void ValidateMapEvents() { var missingEvents = 0; var correctedEvents = 0; - + foreach (var (mapId, databaseObject) in MapController.Lookup) { if (databaseObject is not MapDescriptor mapDescriptor) @@ -909,7 +902,7 @@ private static void ValidateMapEvents() mapId ); } - + foreach (var eventId in mapDescriptor.EventIds) { if (!EventDescriptor.TryGet(eventId, out var eventDescriptor)) diff --git a/Intersect.Server.Core/Database/PlayerData/User.cs b/Intersect.Server.Core/Database/PlayerData/User.cs index 2ca1d0a945..89a49bc31b 100644 --- a/Intersect.Server.Core/Database/PlayerData/User.cs +++ b/Intersect.Server.Core/Database/PlayerData/User.cs @@ -109,6 +109,25 @@ public ulong PlayTimeSeconds public string LastIp { get; set; } + public static bool TryFindOnline(LookupKey lookupKey, [NotNullWhen(true)] out User? user) + { + if (lookupKey.IsId) + { + return OnlineUsers.TryGetValue(lookupKey.Id, out user); + } + + if (lookupKey.IsName) + { + user = OnlineUsers.Values.FirstOrDefault( + onlineUser => string.Equals(lookupKey.Name, onlineUser.Name, StringComparison.OrdinalIgnoreCase) + ); + return user != null; + } + + user = null; + return false; + } + public static User FindOnline(Guid id) => OnlineUsers.ContainsKey(id) ? OnlineUsers[id] : null; public static User FindOnline(string username) => @@ -622,7 +641,7 @@ public static bool TryAuthenticate(string username, string password, [NotNullWhe public static bool TryLogin( string username, - string ptPassword, + string passwordClientHash, [NotNullWhen(true)] out User? user, out LoginFailureReason failureReason ) @@ -632,7 +651,7 @@ out LoginFailureReason failureReason if (user != null) { - var hashedPassword = SaltPasswordHash(ptPassword, user.Salt); + var hashedPassword = SaltPasswordHash(passwordClientHash, user.Salt); if (!string.Equals(user.Password, hashedPassword, StringComparison.Ordinal)) { ApplicationContext.Context.Value?.Logger.LogDebug($"Login to {username} failed due invalid credentials"); @@ -682,10 +701,18 @@ out LoginFailureReason failureReason return false; } - var pass = SaltPasswordHash(ptPassword, salt); - var queriedUser = QueryUserByNameAndPasswordShallow(context, username, pass); + var saltedPasswordHash = SaltPasswordHash(passwordClientHash, salt); + var queriedUser = QueryUserByNameAndPasswordShallow(context, username, saltedPasswordHash); user = PostLoad(queriedUser, context); - return user != default; + + if (user == default) + { + failureReason = new LoginFailureReason(LoginFailureType.InvalidCredentials); + return false; + } + + failureReason = default; + return true; } catch (Exception exception) { @@ -888,32 +915,36 @@ public static User FindByEmail(string email, PlayerContext playerContext) } } - public static string GetUserSalt(string userName) + public static string? GetUserSalt(string username) => TryGetSalt(username, out var salt) ? salt : null; + + public static bool TryGetSalt(string username, [NotNullWhen(true)] out string? salt) { - if (string.IsNullOrWhiteSpace(userName)) - { - return null; - } + ArgumentException.ThrowIfNullOrWhiteSpace(username, nameof(username)); - var user = FindOnline(userName); - if (user != null) + if (TryFindOnline(username, out var user)) { - return user.Salt; + salt = user.Salt; + if (!string.IsNullOrWhiteSpace(salt)) + { + return true; + } } try { using var context = DbInterface.CreatePlayerContext(); - return SaltByName(context, userName); + salt = QuerySaltByName(context, username); + return !string.IsNullOrWhiteSpace(salt); } catch (Exception exception) { ApplicationContext.Context.Value?.Logger.LogError( exception, "Error getting salt for '{Username}'", - userName + username ); - return null; + salt = null; + return false; } } @@ -1284,7 +1315,7 @@ out int total context.Users.Where(u => u.Name == nameOrEmail || u.Email == nameOrEmail).Any() ); - private static readonly Func SaltByName = EF.CompileQuery( + private static readonly Func QuerySaltByName = EF.CompileQuery( // ReSharper disable once SpecifyStringComparison (PlayerContext context, string userName) => context.Users.Where(u => u.Name == userName).Select(u => u.Salt).FirstOrDefault() diff --git a/Intersect.Server.Core/Networking/PacketHandler.cs b/Intersect.Server.Core/Networking/PacketHandler.cs index 34f25fb8d7..91ebc17b49 100644 --- a/Intersect.Server.Core/Networking/PacketHandler.cs +++ b/Intersect.Server.Core/Networking/PacketHandler.cs @@ -23,6 +23,7 @@ using Intersect.Framework.Core.GameObjects.Items; using Intersect.Framework.Core.GameObjects.Maps; using Intersect.Framework.Core.GameObjects.PlayerClass; +using Intersect.Framework.Core.Security; using Intersect.Network.Packets.Server; using Intersect.Server.Core; using Microsoft.Extensions.Logging; @@ -1477,7 +1478,7 @@ public void HandlePacket(Client client, EventInputVariablePacket packet) } //CreateAccountPacket - public void HandlePacket(Client client, CreateAccountPacket packet) + public void HandlePacket(Client client, UserRegistrationRequestPacket packet) { if (client.TimeoutMs > Timing.Global.Milliseconds) { @@ -2672,7 +2673,7 @@ public void HandlePacket(Client client, NewCharacterPacket packet) } //ResetPasswordPacket - public void HandlePacket(Client client, ResetPasswordPacket packet) + public void HandlePacket(Client client, PasswordChangeRequestPacket passwordChangeRequestPacket) { //Find account with that name or email @@ -2684,21 +2685,94 @@ public void HandlePacket(Client client, ResetPasswordPacket packet) return; } - var success = false; - var user = User.FindFromNameOrEmail(packet.NameOrEmail.Trim()); - if (user != null) + var identifier = passwordChangeRequestPacket.Identifier?.Trim(); + if (string.IsNullOrWhiteSpace(identifier)) { - if (user.PasswordResetCode.ToLower().Trim() == packet.ResetCode.ToLower().Trim() && - user.PasswordResetTime > DateTime.UtcNow) + Logger.LogWarning( + "Received {PasswordChangePacket} with empty identifier from {ClientId}", + nameof(PasswordChangeRequestPacket), + client.Id + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.InvalidRequest); + return; + } + + var token = passwordChangeRequestPacket.Token?.Trim(); + if (string.IsNullOrWhiteSpace(token)) + { + Logger.LogWarning( + "Received {PasswordChangePacket} with empty token from {ClientId}", + nameof(PasswordChangeRequestPacket), + client.Id + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.InvalidRequest); + return; + } + + var password = passwordChangeRequestPacket.Password?.Trim(); + if (string.IsNullOrWhiteSpace(password)) + { + Logger.LogWarning( + "Received {PasswordChangePacket} with empty password from {ClientId}", + nameof(PasswordChangeRequestPacket), + client.Id + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.InvalidRequest); + return; + } + + var user = User.FindFromNameOrEmail(identifier); + if (user == null) + { + Logger.LogWarning( + "Received {PasswordChangePacket} from {ClientId} for a user '{MissingIdentifier}' that cannot be found", + nameof(PasswordChangeRequestPacket), + client.Id, + identifier + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.NoUserFound); + return; + } + + if (string.Equals(user.PasswordResetCode, token, StringComparison.OrdinalIgnoreCase)) + { + if (DateTime.UtcNow < user.PasswordResetTime) { user.PasswordResetCode = string.Empty; user.PasswordResetTime = DateTime.MinValue; - DbInterface.ResetPass(user, packet.NewPassword); - success = true; + DbInterface.UpdatePassword(user, passwordChangeRequestPacket.Password); + ApplicationContext.CurrentContext.Logger.LogInformation("Password changed via reset token for {UserId}", user.Id); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.Success); + return; } + + Logger.LogWarning( + "Received {PasswordChangePacket} from {ClientId} for user {UserId} with an expired password reset token", + nameof(PasswordChangeRequestPacket), + client.Id, + user.Id + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.InvalidToken); + return; } - PacketSender.SendPasswordResetResult(client, success); + if (user.IsPasswordValid(token)) + { + user.PasswordResetCode = string.Empty; + user.PasswordResetTime = DateTime.MinValue; + DbInterface.UpdatePassword(user, passwordChangeRequestPacket.Password); + ApplicationContext.CurrentContext.Logger.LogInformation("Password changed via existing password for {UserId}", user.Id); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.Success); + return; + } + + Logger.LogWarning( + "Received {PasswordChangePacket} from {ClientId} for user {UserId} with an invalid token", + nameof(PasswordChangeRequestPacket), + client.Id, + user.Id + ); + PacketSender.SendPasswordResetResult(client, PasswordResetResultType.InvalidToken); } //RequestGuildPacket diff --git a/Intersect.Server.Core/Networking/PacketSender.cs b/Intersect.Server.Core/Networking/PacketSender.cs index 097c9f8d41..26285249c0 100644 --- a/Intersect.Server.Core/Networking/PacketSender.cs +++ b/Intersect.Server.Core/Networking/PacketSender.cs @@ -1363,7 +1363,8 @@ public static void SendPlayerCharacters(Client client, bool skipLoadingRelations if (clientCharacters.Count < 1) { CharactersPacket emptyBulkCharactersPacket = new( - Array.Empty(), + user.Name, + [], client.Characters.Count < Options.Instance.Player.MaxCharacters ); @@ -1434,6 +1435,7 @@ public static void SendPlayerCharacters(Client client, bool skipLoadingRelations } CharactersPacket bulkCharactersPacket = new( + user.Name, characterPackets.ToArray(), client.Characters.Count < Options.Instance.Player.MaxCharacters ); @@ -2236,9 +2238,9 @@ public static void SendFriendRequest(Player player, Player partner) } //PasswordResetResultPacket - public static void SendPasswordResetResult(Client client, bool result) + public static void SendPasswordResetResult(Client client, PasswordResetResultType resultType) { - client.Send(new PasswordResetResultPacket(result)); + client.Send(new PasswordChangeResultPacket(resultType)); } //TargetOverridePacket diff --git a/Intersect.Server/Networking/NetworkedPacketHandler.cs b/Intersect.Server/Networking/NetworkedPacketHandler.cs index 2f8ba1b1f4..7624ecf168 100644 --- a/Intersect.Server/Networking/NetworkedPacketHandler.cs +++ b/Intersect.Server/Networking/NetworkedPacketHandler.cs @@ -65,27 +65,38 @@ public void HandlePacket(Client client, RequestPasswordResetPacket packet) return; } - if (Options.Instance.SmtpValid) + if (!Options.Instance.SmtpValid) { - //Find account with that name or email - var user = User.FindFromNameOrEmail(packet.NameOrEmail.Trim()); - if (user != null) - { - var email = new PasswordResetEmail(user); - if (!email.Send()) - { - PacketSender.SendError(client, Strings.Account.EmailFail, Strings.General.NoticeError); - } - } - else - { - client.FailedAttempt(); - } + client.FailedAttempt(); + return; } - else + + //Find account with that name or email + var user = User.FindFromNameOrEmail(packet.NameOrEmail.Trim()); + if (user == null) { client.FailedAttempt(); + return; + } + + if (!PasswordResetEmail.TryCreate(user, out var passwordResetEmail)) + { + ApplicationContext.CurrentContext.Logger.LogError( + "Failed to create password reset email for {UserName} ({UserId})", + user.Name, + user.Id + ); + PacketSender.SendError(client, Strings.Account.EmailFail, Strings.General.NoticeError); + return; } + + if (!passwordResetEmail.TrySend()) + { + PacketSender.SendError(client, Strings.Account.EmailFail, Strings.General.NoticeError); + return; + } + + ApplicationContext.CurrentContext.Logger.LogInformation("Send password reset email to {UserId}", user.Id); } #region "Editor Packets" diff --git a/Intersect.Server/Notifications/Notification.cs b/Intersect.Server/Notifications/Notification.cs index 90c83641e0..60f256a4f2 100644 --- a/Intersect.Server/Notifications/Notification.cs +++ b/Intersect.Server/Notifications/Notification.cs @@ -1,4 +1,7 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; using Intersect.Core; +using Intersect.Server.Database.PlayerData; using Intersect.Server.Localization; using MailKit.Net.Smtp; using MailKit.Security; @@ -6,136 +9,171 @@ namespace Intersect.Server.Notifications { - public partial class Notification { - - public Notification(string to, string subject = "", bool html = false) + public Notification(string to, string? subject = null, bool html = false) { ToAddress = to; Subject = subject; IsHtml = html; } - public string ToAddress { get; set; } = string.Empty; + public string? Recipient { get; init; } - public string Subject { get; set; } = string.Empty; + public string ToAddress { get; init; } - public string Body { get; set; } = string.Empty; + public string? Subject { get; init; } - public bool IsHtml { get; set; } = false; + public string? Body { get; set; } - public bool Send() + public bool IsHtml { get; set; } + + public bool TrySend() { - //Check and see if smtp is even setup - if (Options.Instance.SmtpSettings.IsValid()) + // If there is no subject log an error + if (string.IsNullOrWhiteSpace(Subject)) + { + ApplicationContext.CurrentContext.Logger.LogError( + "Unable to send email to '{SenderAddress}' because the subject is empty (or whitespace).", + ToAddress + ); + return false; + } + + // If there is no message body log an error + if (string.IsNullOrWhiteSpace(Body)) + { + ApplicationContext.CurrentContext.Logger.LogError( + "Unable to send email ({Subject}) to '{SenderAddress}' because the body is empty (or whitespace).", + Subject, + ToAddress + ); + return false; + } + + // If SMTP is not set up correctly log an error + var smtpSettings = Options.Instance.SmtpSettings; + if (!smtpSettings.IsValid()) { - //Make sure we have a body - if (!string.IsNullOrEmpty(Body)) + ApplicationContext.CurrentContext.Logger.LogError( + "Unable to send email ({Subject}) to '{SenderAddress}' because SMTP is not correctly configured.", + Subject, + ToAddress + ); + return false; + } + + var username = smtpSettings.Username; + var password = smtpSettings.Password; + var shouldAuthenticate = !(string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)); + + try + { + using SmtpClient client = new(); + client.Connect( + smtpSettings.Host, + smtpSettings.Port, + smtpSettings.UseSsl + ? SecureSocketOptions.StartTls + : SecureSocketOptions.Auto + ); + + if (shouldAuthenticate) { - try - { - //Send the email - var fromAddress = new MailboxAddress(Options.Instance.SmtpSettings.FromName, Options.Instance.SmtpSettings.FromAddress); - var toAddress = new MailboxAddress(ToAddress, ToAddress); - - using (var client = new SmtpClient()) - { - client.Connect(Options.Instance.SmtpSettings.Host, Options.Instance.SmtpSettings.Port, Options.Instance.SmtpSettings.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto); - client.Authenticate(Options.Instance.SmtpSettings.Username, Options.Instance.SmtpSettings.Password); - - var message = new MimeMessage(); - message.To.Add(toAddress); - message.From.Add(fromAddress); - message.Subject = Subject; - - var bodyBuilder = new BodyBuilder(); - if (IsHtml) - { - bodyBuilder.HtmlBody = Body; - } - else - { - bodyBuilder.TextBody = Body; - } - message.Body = bodyBuilder.ToMessageBody(); - - client.Send(message); - client.Disconnect(true); - } - - return true; - } - catch (Exception ex) - { - ApplicationContext.Context.Value?.Logger.LogError( - "Failed to send email (Subject: " + - Subject + - ") to " + - ToAddress + - ". Reason: Uncaught Error" + - Environment.NewLine + - ex.ToString() - ); - return false; - } + client.Authenticate(username, password); + } + + var fromAddress = new MailboxAddress(smtpSettings.FromName, smtpSettings.FromAddress); + var toAddress = new MailboxAddress(ToAddress, ToAddress); + + var message = new MimeMessage(); + message.To.Add(toAddress); + message.From.Add(fromAddress); + message.Subject = Subject; + + var bodyBuilder = new BodyBuilder(); + if (IsHtml) + { + bodyBuilder.HtmlBody = Body; } else { - ApplicationContext.Context.Value?.Logger.LogWarning( - "Failed to send email (Subject: " + - Subject + - ") to " + - ToAddress + - ". Reason: SMTP not configured!" - ); - return false; + bodyBuilder.TextBody = Body; } + + message.Body = bodyBuilder.ToMessageBody(); + + client.Send(message); + client.Disconnect(true); + + return true; } - else + catch (Exception ex) { - ApplicationContext.Context.Value?.Logger.LogWarning( - "Failed to send email (Subject: " + Subject + ") to " + ToAddress + ". Reason: SMTP not configured!" + ApplicationContext.Context.Value?.Logger.LogError( + "Failed to send email (Subject: " + + Subject + + ") to " + + ToAddress + + ". Reason: Uncaught Error" + + Environment.NewLine + + ex.ToString() ); return false; } } - protected bool LoadFromTemplate(string templatename, string username) + protected static bool TryLoadTemplate(string templateName, [NotNullWhen(true)] out string? template) { - var templatesDir = Path.Combine("resources", "notifications"); - if (!Directory.Exists(templatesDir)) + var pathToTemplates = Path.Combine("resources", "notifications"); + if (!Directory.Exists(pathToTemplates)) { - Directory.CreateDirectory(templatesDir); + Directory.CreateDirectory(pathToTemplates); } - var filepath = Path.Combine("resources", "notifications", templatename + ".html"); - if (File.Exists(filepath)) + var pathToTemplate = Path.Combine("resources", "notifications", $"{templateName}.html"); + if (!File.Exists(pathToTemplate)) { - IsHtml = true; - Body = File.ReadAllText(filepath); - Body = Body.Replace("{{product}}", Strings.Notifications.Product); - Body = Body.Replace("{{copyright}}", Strings.Notifications.Copyright); - Body = Body.Replace("{{name}}", username); + template = null; + return false; + } + + try + { + template = File.ReadAllText(pathToTemplate); + if (!string.IsNullOrWhiteSpace(template)) + { + return true; + } + + template = null; + return false; - return true; } - else + catch (Exception exception) { - ApplicationContext.Context.Value?.Logger.LogWarning( - "Failed to load email template (Subject: " + - Subject + - ") for " + - ToAddress + - ". Reason: Template " + - templatename + - ".html not found!" + ApplicationContext.CurrentContext.Logger.LogError( + exception, + "Failed to load email template '{TemplateName}'", + templateName ); + template = null; + return false; } + } - return false; + protected static string PopulateBasicTemplate(string template, User user) + { + return template.Replace("{{product}}", Strings.Notifications.Product) + .Replace("{{copyright}}", Strings.Notifications.Copyright) + .Replace("{{name}}", user.Name); } - } + protected static bool IsTemplateHTML(string template) => PatternHtmlTag.IsMatch(template); + + private static readonly Regex PatternHtmlTag = CompilePatternHtmlTag(); + [GeneratedRegex(@"\<[\s\n\r]*html[^\>]*>", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled, "en-US")] + private static partial Regex CompilePatternHtmlTag(); + } } diff --git a/Intersect.Server/Notifications/PasswordResetEmail.cs b/Intersect.Server/Notifications/PasswordResetEmail.cs new file mode 100644 index 0000000000..b726f2eabd --- /dev/null +++ b/Intersect.Server/Notifications/PasswordResetEmail.cs @@ -0,0 +1,78 @@ +using System.Diagnostics.CodeAnalysis; +using Intersect.Core; +using Intersect.Server.Database.PlayerData; +using Intersect.Server.Localization; +using Intersect.Utilities; + +namespace Intersect.Server.Notifications; + +public partial class PasswordResetEmail : Notification +{ + private PasswordResetEmail(User user) : base(user.Email) + { + Recipient = user.Name; + Subject = Strings.PasswordResetNotification.Subject; + } + + public static bool TryCreate(User user, [NotNullWhen(true)] out PasswordResetEmail? passwordResetEmail) + { + if (!TryLoadTemplate("PasswordReset", out var template)) + { + passwordResetEmail = null; + return false; + } + + var applicationContext = ApplicationContext.CurrentContext; + if (string.IsNullOrWhiteSpace(template)) + { + applicationContext.Logger.LogError("Unable to create a password reset email because the template is empty"); + passwordResetEmail = null; + return false; + } + + var resetCode = GenerateResetCode(6); + user.PasswordResetCode = resetCode; + var passwordResetTimeMinutes = Options.Instance.ValidPasswordResetTimeMinutes; + user.PasswordResetTime = DateTime.UtcNow.AddMinutes(passwordResetTimeMinutes); + user.Save(); + + var body = PopulatePasswordResetTemplate(template, user, resetCode, passwordResetTimeMinutes); + if (string.IsNullOrWhiteSpace(body)) + { + applicationContext.Logger.LogError( + "Unable to create a password reset email because the body is empty after populating the template for user '{UserName}' ({UserId})", + user.Name, + user.Id + ); + passwordResetEmail = null; + return false; + } + + var isHtml = IsTemplateHTML(body); + passwordResetEmail = new PasswordResetEmail(user) + { + Body = body, IsHtml = isHtml, + }; + return true; + } + + private static string PopulatePasswordResetTemplate( + string template, + User user, + string resetCode, + int resetCodeExpirationMinutes + ) + { + var body = PopulateBasicTemplate(template, user) + .Replace("{{code}}", resetCode) + .Replace("{{expiration}}", resetCodeExpirationMinutes.ToString()); + return body; + } + + private static string GenerateResetCode(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + return new string(Enumerable.Repeat(chars, length).Select(s => s[Randomization.Next(s.Length)]).ToArray()); + } +} \ No newline at end of file diff --git a/Intersect.Server/Notifications/ResetPassword.cs b/Intersect.Server/Notifications/ResetPassword.cs deleted file mode 100644 index e5be2e2713..0000000000 --- a/Intersect.Server/Notifications/ResetPassword.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Intersect.Server.Database.PlayerData; -using Intersect.Server.Localization; -using Intersect.Utilities; - -namespace Intersect.Server.Notifications -{ - - public partial class PasswordResetEmail : Notification - { - - public PasswordResetEmail(User user) : base(user.Email) - { - LoadFromTemplate("PasswordReset", user.Name); - Subject = Strings.PasswordResetNotification.Subject; - var resetCode = GenerateResetCode(6); - Body = Body.Replace("{{code}}", resetCode); - Body = Body.Replace("{{expiration}}", Options.Instance.ValidPasswordResetTimeMinutes.ToString()); - user.PasswordResetCode = resetCode; - user.PasswordResetTime = DateTime.UtcNow.AddMinutes(Options.Instance.ValidPasswordResetTimeMinutes); - user.Save(); - } - - private string GenerateResetCode(int length) - { - const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - return new string(Enumerable.Repeat(chars, length).Select(s => s[Randomization.Next(s.Length)]).ToArray()); - } - - } - -} diff --git a/Intersect.Server/Web/Controllers/Api/V1/UserController.cs b/Intersect.Server/Web/Controllers/Api/V1/UserController.cs index ae66e5c45e..16e05f6ddb 100644 --- a/Intersect.Server/Web/Controllers/Api/V1/UserController.cs +++ b/Intersect.Server/Web/Controllers/Api/V1/UserController.cs @@ -498,8 +498,13 @@ public IActionResult UserSendPasswordResetEmail(LookupKey lookupKey) return NotFound("Could not send password reset email, SMTP settings on the server are not configured!"); } - var email = new PasswordResetEmail(user); - if (!email.Send()) + if (!PasswordResetEmail.TryCreate(user, out var passwordResetEmail)) + { + return InternalServerError("Failed to create reset email."); + } + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (!passwordResetEmail.TrySend()) { return InternalServerError("Failed to send reset email."); } diff --git a/Intersect.sln.DotSettings b/Intersect.sln.DotSettings index a9ef87baab..7cf5f64930 100644 --- a/Intersect.sln.DotSettings +++ b/Intersect.sln.DotSettings @@ -50,4 +50,5 @@ True True True - True + True + True