From 60693d25dae0e5634f30bcdbaf1cd3ccba647fc3 Mon Sep 17 00:00:00 2001 From: Marcel Baumgartner Date: Thu, 23 Feb 2023 10:36:31 +0100 Subject: [PATCH] Added google and discord oauth2. Fixed menu bugs --- .../Api/Moonlight/OAuth2Controller.cs | 132 ++++++++++++++++ .../Requests/GoogleOAuth2CodePayload.cs | 21 +++ .../Services/OAuth2/DiscordOAuth2Service.cs | 127 +++++++++++++++ .../Services/OAuth2/GoogleOAuth2Service.cs | 149 ++++++++++++++++++ Moonlight/App/Services/UserService.cs | 41 +++-- Moonlight/Moonlight.csproj | 1 + Moonlight/Program.cs | 3 + Moonlight/Shared/Components/Auth/Login.razor | 23 ++- .../Shared/Components/Auth/Register.razor | 29 +++- .../ErrorBoundaries/PageErrorBoundary.razor | 19 +++ Moonlight/Shared/Layouts/MainLayout.razor | 48 ++++-- Moonlight/wwwroot/assets/js/scripts.bundle.js | 9 ++ 12 files changed, 555 insertions(+), 47 deletions(-) create mode 100644 Moonlight/App/Http/Controllers/Api/Moonlight/OAuth2Controller.cs create mode 100644 Moonlight/App/Models/Google/Requests/GoogleOAuth2CodePayload.cs create mode 100644 Moonlight/App/Services/OAuth2/DiscordOAuth2Service.cs create mode 100644 Moonlight/App/Services/OAuth2/GoogleOAuth2Service.cs diff --git a/Moonlight/App/Http/Controllers/Api/Moonlight/OAuth2Controller.cs b/Moonlight/App/Http/Controllers/Api/Moonlight/OAuth2Controller.cs new file mode 100644 index 0000000..93f861e --- /dev/null +++ b/Moonlight/App/Http/Controllers/Api/Moonlight/OAuth2Controller.cs @@ -0,0 +1,132 @@ +using Logging.Net; +using Microsoft.AspNetCore.Mvc; +using Moonlight.App.Exceptions; +using Moonlight.App.Helpers; +using Moonlight.App.Repositories; +using Moonlight.App.Services; +using Moonlight.App.Services.OAuth2; +using Moonlight.App.Services.Sessions; + +namespace Moonlight.App.Http.Controllers.Api.Moonlight; + +[ApiController] +[Route("api/moonlight/oauth2")] +public class OAuth2Controller : Controller +{ + private readonly GoogleOAuth2Service GoogleOAuth2Service; + private readonly DiscordOAuth2Service DiscordOAuth2Service; + private readonly UserRepository UserRepository; + private readonly UserService UserService; + + public OAuth2Controller( + GoogleOAuth2Service googleOAuth2Service, + UserRepository userRepository, + UserService userService, + DiscordOAuth2Service discordOAuth2Service) + { + GoogleOAuth2Service = googleOAuth2Service; + UserRepository = userRepository; + UserService = userService; + DiscordOAuth2Service = discordOAuth2Service; + } + + [HttpGet("google")] + public async Task Google([FromQuery] string code) + { + try + { + var userData = await GoogleOAuth2Service.HandleCode(code); + + if (userData == null) + return Redirect("/login"); + + try + { + var user = UserRepository.Get().FirstOrDefault(x => x.Email == userData.Email); + + string token; + + if (user == null) + { + token = await UserService.Register( + userData.Email, + StringHelper.GenerateString(32), + userData.FirstName, + userData.LastName + ); + } + else + { + token = await UserService.GenerateToken(user); + } + + Response.Cookies.Append("token", token, new () + { + Expires = new DateTimeOffset(DateTime.UtcNow.AddDays(10)) + }); + + return Redirect("/"); + } + catch (Exception e) + { + Logger.Warn(e.Message); + return Redirect("/login"); + } + } + catch (Exception e) + { + Logger.Warn(e.Message); + return BadRequest(); + } + } + + [HttpGet("discord")] + public async Task Discord([FromQuery] string code) + { + try + { + var userData = await DiscordOAuth2Service.HandleCode(code); + + if (userData == null) + return Redirect("/login"); + + try + { + var user = UserRepository.Get().FirstOrDefault(x => x.Email == userData.Email); + + string token; + + if (user == null) + { + token = await UserService.Register( + userData.Email, + StringHelper.GenerateString(32), + userData.FirstName, + userData.LastName + ); + } + else + { + token = await UserService.GenerateToken(user); + } + + Response.Cookies.Append("token", token, new () + { + Expires = new DateTimeOffset(DateTime.UtcNow.AddDays(10)) + }); + + return Redirect("/"); + } + catch (Exception e) + { + Logger.Warn(e.Message); + return Redirect("/login"); + } + } + catch (Exception e) + { + Logger.Warn(e.Message); + return BadRequest(); + } + } +} \ No newline at end of file diff --git a/Moonlight/App/Models/Google/Requests/GoogleOAuth2CodePayload.cs b/Moonlight/App/Models/Google/Requests/GoogleOAuth2CodePayload.cs new file mode 100644 index 0000000..4d47dda --- /dev/null +++ b/Moonlight/App/Models/Google/Requests/GoogleOAuth2CodePayload.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Moonlight.App.Models.Google.Requests; + +public class GoogleOAuth2CodePayload +{ + [JsonProperty("grant_type")] + public string GrantType { get; set; } = "authorization_code"; + + [JsonProperty("code")] + public string Code { get; set; } + + [JsonProperty("client_id")] + public string ClientId { get; set; } + + [JsonProperty("client_secret")] + public string ClientSecret { get; set; } + + [JsonProperty("redirect_uri")] + public string RedirectUri { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/Services/OAuth2/DiscordOAuth2Service.cs b/Moonlight/App/Services/OAuth2/DiscordOAuth2Service.cs new file mode 100644 index 0000000..341f976 --- /dev/null +++ b/Moonlight/App/Services/OAuth2/DiscordOAuth2Service.cs @@ -0,0 +1,127 @@ +using System.Text; +using Logging.Net; +using Moonlight.App.Database.Entities; +using Moonlight.App.Exceptions; +using Moonlight.App.Models.Google.Requests; +using RestSharp; + +namespace Moonlight.App.Services.OAuth2; + +public class DiscordOAuth2Service +{ + private readonly bool Enable; + private readonly string ClientId; + private readonly string ClientSecret; + + private readonly bool EnableOverrideUrl; + private readonly string OverrideUrl; + private readonly string AppUrl; + + public DiscordOAuth2Service(ConfigService configService) + { + var config = configService + .GetSection("Moonlight") + .GetSection("OAuth2"); + + Enable = config + .GetSection("Discord") + .GetValue("Enable"); + + if (Enable) + { + ClientId = config.GetSection("Discord").GetValue("ClientId"); + ClientSecret = config.GetSection("Discord").GetValue("ClientSecret"); + } + + EnableOverrideUrl = config.GetValue("EnableOverrideUrl"); + + if (EnableOverrideUrl) + OverrideUrl = config.GetValue("OverrideUrl"); + + AppUrl = configService.GetSection("Moonlight").GetValue("AppUrl"); + } + + public Task GetUrl() + { + if (!Enable) + throw new DisplayException("Discord OAuth2 not enabled"); + + string url = $"https://discord.com/api/oauth2/authorize?client_id={ClientId}" + + $"&redirect_uri={GetBaseUrl()}/api/moonlight/oauth2/discord" + + "&response_type=code&scope=identify%20email"; + + return Task.FromResult( + url + ); + } + + public async Task HandleCode(string code) + { + // Generate access token + var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/discord"; + var discordEndpoint = "https://discordapp.com/api/oauth2/token"; + + using var client = new RestClient(); + var request = new RestRequest(discordEndpoint); + + request.AddParameter("client_id", ClientId); + request.AddParameter("client_secret", ClientSecret); + request.AddParameter("grant_type", "authorization_code"); + request.AddParameter("code", code); + request.AddParameter("redirect_uri", endpoint); + + var response = await client.ExecutePostAsync(request); + + if (!response.IsSuccessful) + { + //TODO: Maybe add better error handling + Logger.Debug("oAuth2 validate error: " + response.Content!); + return null; + } + + // parse response + + var data = new ConfigurationBuilder().AddJsonStream( + new MemoryStream(Encoding.ASCII.GetBytes(response.Content!)) + ).Build(); + + var accessToken = data.GetValue("access_token"); + + // Now, we will call the google api with our access token to get the data we need + + var googlePeopleEndpoint = "https://discordapp.com/api/users/@me"; + + var getRequest = new RestRequest(googlePeopleEndpoint); + getRequest.AddHeader("Authorization", $"Bearer {accessToken}"); + + var getResponse = await client.ExecuteGetAsync(getRequest); + + if (!getResponse.IsSuccessful) + { + //TODO: Maybe add better error handling + Logger.Debug("OAuth2 api access error: " + getResponse.Content!); + return null; + } + + // Parse response + + var getData = new ConfigurationBuilder().AddJsonStream( + new MemoryStream(Encoding.ASCII.GetBytes(getResponse.Content!)) + ).Build(); + + return new User() + { + Email = getData.GetValue("email"), + FirstName = getData.GetValue("username"), + LastName = getData.GetValue("discriminator") + }; + } + + private string GetBaseUrl() + { + if (EnableOverrideUrl) + return OverrideUrl; + + return AppUrl; + } +} \ No newline at end of file diff --git a/Moonlight/App/Services/OAuth2/GoogleOAuth2Service.cs b/Moonlight/App/Services/OAuth2/GoogleOAuth2Service.cs new file mode 100644 index 0000000..de74203 --- /dev/null +++ b/Moonlight/App/Services/OAuth2/GoogleOAuth2Service.cs @@ -0,0 +1,149 @@ +using System.Text; +using Logging.Net; +using Moonlight.App.Database.Entities; +using Moonlight.App.Exceptions; +using Moonlight.App.Models.Google.Requests; +using RestSharp; + +namespace Moonlight.App.Services.OAuth2; + +public class GoogleOAuth2Service +{ + private readonly bool EnableGoogle; + private readonly string GoogleClientId; + private readonly string GoogleClientSecret; + + private readonly bool EnableOverrideUrl; + private readonly string OverrideUrl; + private readonly string AppUrl; + + public GoogleOAuth2Service(ConfigService configService) + { + var config = configService + .GetSection("Moonlight") + .GetSection("OAuth2"); + + EnableGoogle = config + .GetSection("Google") + .GetValue("Enable"); + + if (EnableGoogle) + { + GoogleClientId = config.GetSection("Google").GetValue("ClientId"); + GoogleClientSecret = config.GetSection("Google").GetValue("ClientSecret"); + } + + EnableOverrideUrl = config.GetValue("EnableOverrideUrl"); + + if (EnableOverrideUrl) + OverrideUrl = config.GetValue("OverrideUrl"); + + AppUrl = configService.GetSection("Moonlight").GetValue("AppUrl"); + } + + public Task GetUrl() + { + if (!EnableGoogle) + throw new DisplayException("Google OAuth2 not enabled"); + + var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/google"; + var scope = "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email"; + + return Task.FromResult( + $"https://accounts.google.com/o/oauth2/auth?response_type=code&client_id={GoogleClientId}&redirect_uri={endpoint}&scope={scope}" + ); + } + + public async Task HandleCode(string code) + { + // Generate access token + var endpoint = GetBaseUrl() + "/api/moonlight/oauth2/google"; + var googleEndpoint = "https://oauth2.googleapis.com/token"; + + // Setup payload + var payload = new GoogleOAuth2CodePayload() + { + Code = code, + RedirectUri = endpoint, + ClientId = GoogleClientId, + ClientSecret = GoogleClientSecret + }; + + using var client = new RestClient(); + var request = new RestRequest(googleEndpoint); + + request.AddBody(payload); + var response = await client.ExecutePostAsync(request); + + if (!response.IsSuccessful) + { + //TODO: Maybe add better error handling + Logger.Debug("oAuth2 validate error: " + response.Content!); + return null; + } + + // parse response + + var data = new ConfigurationBuilder().AddJsonStream( + new MemoryStream(Encoding.ASCII.GetBytes(response.Content!)) + ).Build(); + + var accessToken = data.GetValue("access_token"); + + // Now, we will call the google api with our access token to get the data we need + + var googlePeopleEndpoint = "https://people.googleapis.com/v1/people/me"; + + var getRequest = new RestRequest(googlePeopleEndpoint); + getRequest.AddHeader("Authorization", $"Bearer {accessToken}"); + getRequest.AddParameter("personFields", "names,emailAddresses"); + + var getResponse = await client.ExecuteGetAsync(getRequest); + + if (!getResponse.IsSuccessful) + { + //TODO: Maybe add better error handling + Logger.Debug("OAuth2 api access error: " + getResponse.Content!); + return null; + } + + // Parse response + + var getData = new ConfigurationBuilder().AddJsonStream( + new MemoryStream(Encoding.ASCII.GetBytes(getResponse.Content!)) + ).Build(); + + var firstName = getData + .GetSection("names") + .GetChildren() + .First() + .GetValue("givenName"); + + var lastName = getData + .GetSection("names") + .GetChildren() + .First() + .GetValue("familyName"); + + var email = getData + .GetSection("emailAddresses") + .GetChildren() + .First() + .GetValue("value"); + + return new() + { + Email = email, + FirstName = firstName, + LastName = lastName + }; + } + + private string GetBaseUrl() + { + if (EnableOverrideUrl) + return OverrideUrl; + + return AppUrl; + } +} \ No newline at end of file diff --git a/Moonlight/App/Services/UserService.cs b/Moonlight/App/Services/UserService.cs index e0c82d8..73fd7f9 100644 --- a/Moonlight/App/Services/UserService.cs +++ b/Moonlight/App/Services/UserService.cs @@ -68,13 +68,7 @@ public class UserService //var mail = new WelcomeMail(user); //await MailService.Send(mail, user); - return JwtBuilder.Create() - .WithAlgorithm(new HMACSHA256Algorithm()) - .WithSecret(JwtSecret) - .AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds()) - .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) - .AddClaim("userid", user.Id) - .Encode(); + return await GenerateToken(user); } public Task CheckTotp(string email, string password) @@ -123,13 +117,7 @@ public class UserService { //AuditLogService.Log("login:success", $"{user.Email} has successfully logged in"); - return JwtBuilder.Create() - .WithAlgorithm(new HMACSHA256Algorithm()) - .WithSecret(JwtSecret) - .AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds()) - .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) - .AddClaim("userid", user.Id) - .Encode(); + return await GenerateToken(user); } else { @@ -141,17 +129,11 @@ public class UserService { //AuditLogService.Log("login:success", $"{user.Email} has successfully logged in"); - return JwtBuilder.Create() - .WithAlgorithm(new HMACSHA256Algorithm()) - .WithSecret(JwtSecret) - .AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds()) - .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) - .AddClaim("userid", user.Id) - .Encode(); + return await GenerateToken(user!); } } - public async Task ChangePassword(User user, string password) + public Task ChangePassword(User user, string password) { user.Password = BCrypt.Net.BCrypt.HashPassword(password); user.TokenValidTime = DateTime.Now; @@ -161,6 +143,8 @@ public class UserService //await MailService.Send(mail, user); //AuditLogService.Log("password:change", "The password has been set to a new one"); + + return Task.CompletedTask; } public Task SftpLogin(int id, string password) @@ -179,4 +163,17 @@ public class UserService //TODO: Log throw new Exception("Invalid userid or password"); } + + public Task GenerateToken(User user) + { + var token = JwtBuilder.Create() + .WithAlgorithm(new HMACSHA256Algorithm()) + .WithSecret(JwtSecret) + .AddClaim("exp", DateTimeOffset.UtcNow.AddDays(10).ToUnixTimeSeconds()) + .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + .AddClaim("userid", user.Id) + .Encode(); + + return Task.FromResult(token); + } } \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index 5e319e8..8c95a82 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -59,6 +59,7 @@ + diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index f44eede..145e06a 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -7,6 +7,7 @@ using Moonlight.App.Repositories; using Moonlight.App.Repositories.Servers; using Moonlight.App.Services; using Moonlight.App.Services.Interop; +using Moonlight.App.Services.OAuth2; using Moonlight.App.Services.Sessions; using Moonlight.App.Services.Support; @@ -56,6 +57,8 @@ namespace Moonlight builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Moonlight/Shared/Components/Auth/Login.razor b/Moonlight/Shared/Components/Auth/Login.razor index 5dfe2fb..52936ee 100644 --- a/Moonlight/Shared/Components/Auth/Login.razor +++ b/Moonlight/Shared/Components/Auth/Login.razor @@ -9,6 +9,7 @@ @using Moonlight.App.Services @using Moonlight.App.Exceptions @using Logging.Net +@using Moonlight.App.Services.OAuth2 @using Moonlight.App.Services.Sessions @inject AlertService AlertService @@ -16,6 +17,8 @@ @inject SmartTranslateService SmartTranslateService @inject CookieService CookieService @inject NavigationManager NavigationManager +@inject GoogleOAuth2Service GoogleOAuth2Service +@inject DiscordOAuth2Service DiscordOAuth2Service
@@ -35,17 +38,17 @@
- +
- +
Sign in with Google
@@ -158,4 +161,16 @@ Logger.Error(e); } } + + private async Task DoGoogle() + { + var url = await GoogleOAuth2Service.GetUrl(); + NavigationManager.NavigateTo(url, true); + } + + private async Task DoDiscord() + { + var url = await DiscordOAuth2Service.GetUrl(); + NavigationManager.NavigateTo(url, true); + } } \ No newline at end of file diff --git a/Moonlight/Shared/Components/Auth/Register.razor b/Moonlight/Shared/Components/Auth/Register.razor index e3fbf61..ab8010b 100644 --- a/Moonlight/Shared/Components/Auth/Register.razor +++ b/Moonlight/Shared/Components/Auth/Register.razor @@ -6,8 +6,12 @@ *@ @using Moonlight.App.Services +@using Moonlight.App.Services.OAuth2 @inject SmartTranslateService SmartTranslateService +@inject GoogleOAuth2Service GoogleOAuth2Service +@inject NavigationManager NavigationManager +@inject DiscordOAuth2Service DiscordOAuth2Service
@@ -25,17 +29,17 @@
-
\ No newline at end of file +
+ +@code +{ + private async Task DoGoogle() + { + var url = await GoogleOAuth2Service.GetUrl(); + NavigationManager.NavigateTo(url, true); + } + + private async Task DoDiscord() + { + var url = await DiscordOAuth2Service.GetUrl(); + NavigationManager.NavigateTo(url, true); + } +} diff --git a/Moonlight/Shared/Components/ErrorBoundaries/PageErrorBoundary.razor b/Moonlight/Shared/Components/ErrorBoundaries/PageErrorBoundary.razor index 44678f0..df5f3b4 100644 --- a/Moonlight/Shared/Components/ErrorBoundaries/PageErrorBoundary.razor +++ b/Moonlight/Shared/Components/ErrorBoundaries/PageErrorBoundary.razor @@ -1,10 +1,14 @@ @using Logging.Net +@using Moonlight.App.Exceptions @using Moonlight.App.Services +@using Moonlight.App.Services.Interop @using Moonlight.App.Services.Sessions @inherits ErrorBoundary @inject IdentityService IdentityService +@inject AlertService AlertService +@inject SmartTranslateService SmartTranslateService @if (CurrentException is null) { @@ -60,6 +64,21 @@ else Logger.Error(exception); await base.OnErrorAsync(exception); + + if (exception is DisplayException displayException) + { + Task.Run(async () => + { + await AlertService.Error( + SmartTranslateService.Translate("Error"), + SmartTranslateService.Translate(displayException.Message) + ); + }); + + Recover(); + + await InvokeAsync(StateHasChanged); + } } public new void Recover() diff --git a/Moonlight/Shared/Layouts/MainLayout.razor b/Moonlight/Shared/Layouts/MainLayout.razor index cf9ea07..ba91074 100644 --- a/Moonlight/Shared/Layouts/MainLayout.razor +++ b/Moonlight/Shared/Layouts/MainLayout.razor @@ -8,6 +8,7 @@ @using Moonlight.App.Services @using Moonlight.App.Services.Interop @using Moonlight.App.Services.Sessions +@using Logging.Net @layout ThemeInit @implements IDisposable @@ -31,7 +32,7 @@ { if (!string.IsNullOrEmpty(pathPart)) { - if(pathPart == pathParts.Last()) + if (pathPart == pathParts.Last()) title += $"{pathPart.FirstCharToUpper()} "; else title += $"{pathPart.FirstCharToUpper()} - "; @@ -128,11 +129,6 @@ AddBodyAttribute("data-kt-app-toolbar-enabled", "true"); AddBodyClass("app-default"); - - JsRuntime.InvokeVoidAsync("KTModalUpgradePlan.init"); - JsRuntime.InvokeVoidAsync("KTCreateApp.init"); - JsRuntime.InvokeVoidAsync("KTModalUserSearch.init"); - JsRuntime.InvokeVoidAsync("KTModalNewTarget.init"); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -142,9 +138,7 @@ try { User = await IdentityService.Get(); - await InvokeAsync(StateHasChanged); - await Task.Delay(300); await JsRuntime.InvokeVoidAsync("document.body.removeAttribute", "data-kt-app-reset-transition"); await JsRuntime.InvokeVoidAsync("document.body.removeAttribute", "data-kt-app-page-loading"); @@ -157,19 +151,24 @@ NavigationManager.LocationChanged += (sender, args) => { SessionService.Refresh(); }; MessageService.Subscribe( - $"support.{User.Id}.message", - this, + $"support.{User.Id}.message", + this, async message => - { - if (!NavigationManager.Uri.EndsWith("/support") && (message.IsSupport || message.IsSystem)) { - await ToastService.Info($"Support: {message.Message}"); - } - }); + if (!NavigationManager.Uri.EndsWith("/support") && (message.IsSupport || message.IsSystem)) + { + await ToastService.Info($"Support: {message.Message}"); + } + }); + + RunDelayedMenu(0); + RunDelayedMenu(1); + RunDelayedMenu(3); + RunDelayedMenu(5); } catch (Exception) { - // ignored + // ignored } } } @@ -193,4 +192,21 @@ { JsRuntime.InvokeVoidAsync("document.body.classList.add", className); } + + private void RunDelayedMenu(int seconds) + { + Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(seconds)); + await JsRuntime.InvokeVoidAsync("KTMenu.initHandlers"); + } + catch (Exception e) + { + Logger.Warn("Delayed menu error"); + Logger.Warn(e); + } + }); + } } \ No newline at end of file diff --git a/Moonlight/wwwroot/assets/js/scripts.bundle.js b/Moonlight/wwwroot/assets/js/scripts.bundle.js index 86a3060..a9a4751 100644 --- a/Moonlight/wwwroot/assets/js/scripts.bundle.js +++ b/Moonlight/wwwroot/assets/js/scripts.bundle.js @@ -2462,8 +2462,17 @@ KTMenu.updateDropdowns = function() { } } +// Bug fix for menu load initializing +KTMenu.hasInit = false; + // Global handlers KTMenu.initHandlers = function() { + + if(KTMenu.hasInit) + return; + + KTMenu.hasInit = true; + // Dropdown handler document.addEventListener("click", function(e) { var items = document.querySelectorAll('.show.menu-dropdown[data-kt-menu-trigger]');