Merge pull request #1 from Moonlight-Panel/ServiceManager

Service manager
This commit is contained in:
Spielepapagei 2023-03-13 21:14:14 +01:00 committed by GitHub
commit faa4fc6e18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1158 additions and 163 deletions

View file

@ -39,6 +39,8 @@ public class DataContext : DbContext
public DbSet<Revoke> Revokes { get; set; }
public DbSet<NotificationClient> NotificationClients { get; set; }
public DbSet<NotificationAction> NotificationActions { get; set; }
public DbSet<AaPanel> AaPanels { get; set; }
public DbSet<Website> Websites { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View file

@ -0,0 +1,9 @@
namespace Moonlight.App.Database.Entities;
public class AaPanel
{
public int Id { get; set; }
public string Url { get; set; } = "";
public string Key { get; set; } = "";
public string BaseDomain { get; set; } = "";
}

View file

@ -3,6 +3,8 @@
public class Database
{
public int Id { get; set; }
public int AaPanelId { get; set; }
public int InternalAaPanelId { get; set; }
public User Owner { get; set; }
public AaPanel AaPanel { get; set; }
public string Name { get; set; }
}

View file

@ -0,0 +1,13 @@
namespace Moonlight.App.Database.Entities;
public class Website
{
public int Id { get; set; }
public int InternalAaPanelId { get; set; }
public AaPanel AaPanel { get; set; }
public User Owner { get; set; }
public string DomainName { get; set; }
public string PhpVersion { get; set; }
public string FtpUsername { get; set; }
public string FtpPassword { get; set; }
}

View file

@ -18,5 +18,6 @@ public enum AuditLogType
DisableTotp,
AddDomainRecord,
UpdateDomainRecord,
DeleteDomainRecord
DeleteDomainRecord,
PasswordReset
}

View file

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database;
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Repositories;
public class AaPanelRepository : IDisposable
{
private readonly DataContext DataContext;
public AaPanelRepository(DataContext dataContext)
{
DataContext = dataContext;
}
public DbSet<AaPanel> Get()
{
return DataContext.AaPanels;
}
public AaPanel Add(AaPanel aaPanel)
{
var x = DataContext.AaPanels.Add(aaPanel);
DataContext.SaveChanges();
return x.Entity;
}
public void Update(AaPanel aaPanel)
{
DataContext.AaPanels.Update(aaPanel);
DataContext.SaveChanges();
}
public void Delete(AaPanel aaPanel)
{
DataContext.AaPanels.Remove(aaPanel);
DataContext.SaveChanges();
}
public void Dispose()
{
DataContext.Dispose();
}
}

View file

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database;
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Repositories;
public class WebsiteRepository : IDisposable
{
private readonly DataContext DataContext;
public WebsiteRepository(DataContext dataContext)
{
DataContext = dataContext;
}
public DbSet<Website> Get()
{
return DataContext.Websites;
}
public Website Add(Website website)
{
var x = DataContext.Websites.Add(website);
DataContext.SaveChanges();
return x.Entity;
}
public void Update(Website website)
{
DataContext.Websites.Update(website);
DataContext.SaveChanges();
}
public void Delete(Website website)
{
DataContext.Websites.Remove(website);
DataContext.SaveChanges();
}
public void Dispose()
{
DataContext.Dispose();
}
}

View file

@ -0,0 +1,76 @@
using aaPanelSharp;
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services;
public class DatabaseService
{
private readonly DatabaseRepository DatabaseRepository;
private readonly AaPanelRepository AaPanelRepository;
public DatabaseService(DatabaseRepository databaseRepository, AaPanelRepository aaPanelRepository)
{
DatabaseRepository = databaseRepository;
AaPanelRepository = aaPanelRepository;
}
public Task<Database.Entities.Database> Create(string name, string password, User u, AaPanel? a = null)
{
if (DatabaseRepository.Get().Any(x => x.Name == name))
throw new DisplayException("A database with this name has been already created");
var aaPanel = a ?? AaPanelRepository.Get().First();
var access = new aaPanel(a!.Url, a.Key);
if (access.CreateDatabase(name, name, password))
{
var aaDb = access.Databases.First(x => x.Name == name);
return Task.FromResult(DatabaseRepository.Add(new()
{
Name = name,
AaPanel = aaPanel,
Owner = u,
InternalAaPanelId = aaDb.Id
}));
}
else
throw new DisplayException("An unknown error occured while creating the database");
}
public Task ChangePassword(Database.Entities.Database database, string newPassword)
{
var access = CreateApiAccess(database);
access.Databases.First(x => x.Id == database.InternalAaPanelId).ChangePassword(newPassword);
return Task.CompletedTask;
}
public Task<string> GetPassword(Database.Entities.Database database)
{
var access = CreateApiAccess(database);
return Task.FromResult(
access.Databases
.First(x => x.Id == database.InternalAaPanelId).Password
);
}
private aaPanel CreateApiAccess(Database.Entities.Database database)
{
if (database.AaPanel == null)
{
database = DatabaseRepository
.Get()
.Include(x => x.AaPanel)
.First(x => x.Id == database.Id);
}
return new aaPanel(database.AaPanel.Url, database.AaPanel.Key);
}
}

View file

@ -52,7 +52,7 @@ public class MailService
try
{
using var client = new SmtpClient();
client.Host = Server;
client.Port = Port;
client.EnableSsl = true;
@ -67,8 +67,6 @@ public class MailService
Subject = $"Hey {user.FirstName}, there are news from moonlight",
To = { new MailAddress(user.Email) }
});
Logger.Debug("Send!");
}
catch (Exception e)
{

View file

@ -0,0 +1,46 @@
using System.Net;
using Logging.Net;
namespace Moonlight.App.Services;
public class TrashMailDetectorService
{
private string[] Domains;
public TrashMailDetectorService()
{
Logger.Info("Fetching trash mail list from github repository");
using var wc = new WebClient();
var lines = wc
.DownloadString("https://raw.githubusercontent.com/Endelon-Hosting/TrashMailDomainDetector/main/trashmail_domains.md")
.Replace("\r\n", "\n")
.Split(new [] { "\n" }, StringSplitOptions.RemoveEmptyEntries);
Domains = GetDomains(lines).ToArray();
}
private IEnumerable<string> GetDomains(string[] lines)
{
foreach (var line in lines)
{
if (!string.IsNullOrWhiteSpace(line))
{
if (line.Contains("."))
{
var domain = line.Remove(0, line.IndexOf(".", StringComparison.Ordinal) + 1).Trim();
if (domain.Contains("."))
{
yield return domain;
}
}
}
}
}
public bool IsTrashEmail(string mail)
{
return Domains.Contains(mail.Split('@')[1]);
}
}

View file

@ -2,6 +2,7 @@
using JWT.Builder;
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Misc;
using Moonlight.App.Repositories;
using Moonlight.App.Services.LogServices;
@ -67,13 +68,13 @@ public class UserService
LastName = lastname,
State = "",
Status = UserStatus.Unverified,
CreatedAt = DateTime.Now,
CreatedAt = DateTime.UtcNow,
DiscordDiscriminator = "",
DiscordId = -1,
DiscordUsername = "",
TotpEnabled = false,
TotpSecret = "",
UpdatedAt = DateTime.Now,
UpdatedAt = DateTime.UtcNow,
TokenValidTime = DateTime.Now.AddDays(-5)
});
@ -142,20 +143,27 @@ public class UserService
}
}
public async Task ChangePassword(User user, string password)
public async Task ChangePassword(User user, string password, bool isSystemAction = false)
{
user.Password = BCrypt.Net.BCrypt.HashPassword(password);
user.TokenValidTime = DateTime.Now;
UserRepository.Update(user);
await MailService.SendMail(user!, "passwordChange", values =>
if (isSystemAction)
{
values.Add("Ip", IdentityService.GetIp());
values.Add("Device", IdentityService.GetDevice());
values.Add("Location", "In your walls");
});
await AuditLogService.LogSystem(AuditLogType.ChangePassword, user.Email);
}
else
{
await MailService.SendMail(user!, "passwordChange", values =>
{
values.Add("Ip", IdentityService.GetIp());
values.Add("Device", IdentityService.GetDevice());
values.Add("Location", "In your walls");
});
await AuditLogService.Log(AuditLogType.ChangePassword, user.Email);
await AuditLogService.Log(AuditLogType.ChangePassword, user.Email);
}
}
public async Task<User> SftpLogin(int id, string password)
@ -197,4 +205,29 @@ public class UserService
return token;
}
public async Task ResetPassword(string email)
{
email = email.ToLower();
var user = UserRepository
.Get()
.FirstOrDefault(x => x.Email == email);
if (user == null)
throw new DisplayException("A user with this email can not be found");
var newPassword = StringHelper.GenerateString(16);
await ChangePassword(user, newPassword, true);
await AuditLogService.Log(AuditLogType.PasswordReset);
await MailService.SendMail(user, "passwordReset", values =>
{
values.Add("Ip", IdentityService.GetIp());
values.Add("Device", IdentityService.GetDevice());
values.Add("Location", "In your walls");
values.Add("Password", newPassword);
});
}
}

View file

@ -0,0 +1,39 @@
using aaPanelSharp;
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Exceptions;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services;
public class WebsiteService
{
private readonly WebsiteRepository WebsiteRepository;
public WebsiteService(WebsiteRepository websiteRepository)
{
WebsiteRepository = websiteRepository;
}
public Website Create(AaPanel aaPanel, User user, string name)
{
if (WebsiteRepository.Get().Any(x => x.DomainName == name))
throw new DisplayException("A website with this domain has already been created");
var access = new aaPanel(aaPanel.Url, aaPanel.Key);
return null;
}
private aaPanel CreateApiAccess(Website website)
{
if (website.AaPanel == null)
{
website = WebsiteRepository
.Get()
.Include(x => x.AaPanel)
.First(x => x.Id == website.Id);
}
return new aaPanel(website.AaPanel.Url, website.AaPanel.Key);
}
}

View file

@ -56,6 +56,8 @@
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\ICON-LICENSE" />
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\README.md" />
<_ContentIncludedByDefault Remove="wwwroot\css\site.css" />
<_ContentIncludedByDefault Remove="Shared\Components\Tables\Column.razor" />
<_ContentIncludedByDefault Remove="Shared\Components\Tables\Table.razor" />
</ItemGroup>
<ItemGroup>

View file

@ -62,6 +62,8 @@ namespace Moonlight
builder.Services.AddScoped<SubscriptionLimitRepository>();
builder.Services.AddScoped<RevokeRepository>();
builder.Services.AddScoped<NotificationRepository>();
builder.Services.AddScoped<AaPanelRepository>();
builder.Services.AddScoped<WebsiteRepository>();
builder.Services.AddScoped<AuditLogEntryRepository>();
builder.Services.AddScoped<ErrorLogEntryRepository>();
@ -100,6 +102,8 @@ namespace Moonlight
builder.Services.AddScoped<ErrorLogService>();
builder.Services.AddScoped<LogService>();
builder.Services.AddScoped<MailService>();
builder.Services.AddSingleton<TrashMailDetectorService>();
builder.Services.AddScoped<WebsiteService>();
// Support
builder.Services.AddSingleton<SupportServerService>();

View file

@ -72,7 +72,7 @@
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-8">
<div></div>
<a href="/reset-password" class="link-primary">
<a href="/passwordreset" class="link-primary">
<TL>Forgot password?</TL>
</a>
</div>

View file

@ -0,0 +1,73 @@
@page "/passwordreset"
@using Moonlight.App.Services
@* This is just a "virtual" route/page. The handling for that is
@* MainLayout doing for us. We need to put that here so the router
@* does not return the 404 page
*@
@inject UserService UserService
@inject SmartTranslateService SmartTranslateService
<div class="d-flex flex-center">
<div class="card rounded-3 w-md-550px">
<div class="card-body">
<div class="d-flex flex-center flex-column-fluid pb-15 pb-lg-20">
@if (Send)
{
<div class="text-center mb-11">
<h1 class="text-dark fw-bolder mb-3">
<TL>Passwort reset successfull. Check your mail</TL>
</h1>
</div>
}
else
{
<div class="form w-100 fv-plugins-bootstrap5 fv-plugins-framework" novalidate="novalidate">
<div class="text-center mb-11">
<h1 class="text-dark fw-bolder mb-3">
<TL>Password reset</TL>
</h1>
<div class="text-gray-500 fw-semibold fs-6">
<TL>Reset the password of your account</TL>
</div>
</div>
<div class="fv-row mb-8 fv-plugins-icon-container">
<input @bind="Email" type="email" placeholder="@(SmartTranslateService.Translate("Email"))" name="email" class="form-control bg-transparent">
</div>
<div class="d-grid mb-10">
<WButton Text="@(SmartTranslateService.Translate("Reset password"))"
WorkingText="@(SmartTranslateService.Translate("Working"))"
CssClasses="btn-primary"
OnClick="Submit">
</WButton>
</div>
<div class="text-gray-500 text-center fw-semibold fs-6">
<TL>Wrong here?</TL>
<a href="/login" class="link-primary">
<TL>Sign in</TL>
</a>
</div>
</div>
}
</div>
</div>
</div>
</div>
@code
{
private string Email = "";
private bool Send = false;
private async Task Submit()
{
await UserService.ResetPassword(Email);
Send = true;
await InvokeAsync(StateHasChanged);
}
}

View file

@ -34,6 +34,11 @@
<TL>Resources</TL>
</a>
</li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 6 ? "active" : "")" href="/admin/system/discordbot">
<TL>Discord bot</TL>
</a>
</li>
</ul>
</div>
</div>

View file

@ -62,6 +62,7 @@
<PageErrorBoundary>
<SoftErrorBoundary>
@if (uri.LocalPath != "/login" &&
uri.LocalPath != "/passwordreset" &&
uri.LocalPath != "/register")
{
if (User == null)
@ -94,6 +95,10 @@
{
<Register></Register>
}
else if (uri.LocalPath == "/passwordreset")
{
<PasswordReset></PasswordReset>
}
}
</SoftErrorBoundary>
</PageErrorBoundary>

View file

@ -0,0 +1,2 @@
@page "/admin/aapanels"

View file

@ -0,0 +1,5 @@
@page "/admin/databases"
<OnlyAdmin>
</OnlyAdmin>

View file

@ -10,38 +10,54 @@
<OnlyAdmin>
<LazyLoader Load="Load">
<div class="row mb-5">
<a class="btn btn-success" href="/admin/domains/new">Add new domain</a>
</div>
<div class="row mb-5">
<Table TableItem="Domain" Items="Domains" PageSize="25" TableHeadClass="border-bottom border-gray-200 fs-6 text-gray-600 fw-bold bg-light bg-opacity-75">
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Shared domain"))" Field="@(x => x.SharedDomain)" Sortable="true" Filterable="true" Width="10%">
<Template>
<span>@(context.SharedDomain.Name)</span>
</Template>
</Column>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Owner"))" Field="@(x => x.Owner)" Sortable="true" Filterable="true" Width="10%">
<Template>
<a class="invisible-a" href="/admin/users/view/@(context.Owner.Id)">@(context.Owner.Email)</a>
</Template>
</Column>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false" Width="10%">
<Template>
<a class="invisible-a" href="/domain/@(context.Id)">Manage</a>
</Template>
</Column>
<Column TableItem="Domain" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false" Width="10%">
<Template>
<WButton Text="@(SmartTranslateService.Translate("Delete"))"
WorkingText="@(SmartTranslateService.Translate("Deleting"))"
CssClasses="btn-danger"
OnClick="() => Delete(context)">
</WButton>
</Template>
</Column>
</Table>
<div class="row">
<div class="card">
<div class="card-header border-0 pt-5">
<h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold fs-3 mb-1">
<TL>Domains</TL>
</span>
</h3>
<div class="card-toolbar">
<a href="/admin/domains/new" class="btn btn-sm btn-light-success">
<i class="bx bx-layer-plus"></i>
<TL>New domain</TL>
</a>
</div>
</div>
<div class="card-body pt-0">
<div class="table-responsive">
<Table TableItem="Domain" Items="Domains" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Shared domain"))" Field="@(x => x.SharedDomain)" Sortable="true" Filterable="true" Width="10%">
<Template>
<span>@(context.SharedDomain.Name)</span>
</Template>
</Column>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Owner"))" Field="@(x => x.Owner)" Sortable="true" Filterable="true" Width="10%">
<Template>
<a href="/admin/users/view/@(context.Owner.Id)">@(context.Owner.Email)</a>
</Template>
</Column>
<Column TableItem="Domain" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false" Width="10%">
<Template>
<a href="/domain/@(context.Id)">Manage</a>
</Template>
</Column>
<Column TableItem="Domain" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false" Width="10%">
<Template>
<WButton Text="@(SmartTranslateService.Translate("Delete"))"
WorkingText="@(SmartTranslateService.Translate("Deleting"))"
CssClasses="btn-sm btn-danger"
OnClick="() => Delete(context)">
</WButton>
</Template>
</Column>
</Table>
</div>
</div>
</div>
</div>
</LazyLoader>
</OnlyAdmin>

View file

@ -9,45 +9,53 @@
@inject SmartTranslateService SmartTranslateService
<OnlyAdmin>
<div class="row mb-5">
<div class="card card-body">
<a href="/admin/servers/new" class="btn btn-success">
<TL>Create new server</TL>
</a>
</div>
</div>
<div class="row">
<LazyLoader Load="Load">
<div class="card card-body">
@if (Servers.Any())
{
<Table TableItem="Server" Items="Servers" PageSize="25" TableHeadClass="border-bottom border-gray-200 fs-6 text-gray-600 fw-bold bg-light bg-opacity-75">
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Sortable="true" Filterable="true" Width="20%"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Cores"))" Field="@(x => x.Cpu)" Sortable="true" Filterable="true" Width="20%"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Memory"))" Field="@(x => x.Memory)" Sortable="true" Filterable="true" Width="20%"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Disk"))" Field="@(x => x.Disk)" Sortable="true" Filterable="true" Width="20%"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Owner"))" Field="@(x => x.Owner)" Sortable="true" Filterable="true" Width="20%">
<Template>
<a href="/admin/users/@(context.Owner.Id)/">@context.Owner.Email</a>
</Template>
</Column>
<Column TableItem="Server" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false" Width="20%">
<Template>
<a href="/admin/servers/edit/@(context.Id)">
@(SmartTranslateService.Translate("Manage"))
</a>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
}
else
{
<div class="alert alert-info">
<TL>No servers found</TL>
<div class="card">
<div class="card-header border-0 pt-5">
<h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold fs-3 mb-1"><TL>Servers</TL></span>
</h3>
<div class="card-toolbar">
<a href="/admin/servers/new" class="btn btn-sm btn-light-success">
<i class="bx bx-layer-plus"></i>
<TL>New server</TL>
</a>
</div>
}
</div>
<div class="card-body pt-0">
@if (Servers.Any())
{
<div class="table-responsive">
<Table TableItem="Server" Items="Servers" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Sortable="true" Filterable="true"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Cores"))" Field="@(x => x.Cpu)" Sortable="true" Filterable="true"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Memory"))" Field="@(x => x.Memory)" Sortable="true" Filterable="true"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Disk"))" Field="@(x => x.Disk)" Sortable="true" Filterable="true"/>
<Column TableItem="Server" Title="@(SmartTranslateService.Translate("Owner"))" Field="@(x => x.Owner)" Sortable="true" Filterable="true">
<Template>
<a href="/admin/users/view/@(context.Owner.Id)/">@context.Owner.Email</a>
</Template>
</Column>
<Column TableItem="Server" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false">
<Template>
<a href="/admin/servers/edit/@(context.Id)">
@(SmartTranslateService.Translate("Manage"))
</a>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
}
else
{
<div class="alert alert-info">
<TL>No servers found</TL>
</div>
}
</div>
</div>
</LazyLoader>
</div>
@ -63,7 +71,7 @@
.Get()
.Include(x => x.Owner)
.ToArray();
return Task.CompletedTask;
}
}

View file

@ -125,37 +125,45 @@
</div>
</div>
@if (Image != null)
{
<div class="mt-9 row d-flex">
@foreach (var vars in ServerVariables.Chunk(4))
<div class="row mb-5">
<div class="card card-body">
@if (Image != null)
{
<div class="row mb-3">
@foreach (var variable in vars)
<div class="mt-9 row d-flex">
@foreach (var vars in ServerVariables.Chunk(4))
{
<div class="col">
<div class="card card-body">
<label class="form-label"><TL>Name</TL></label>
<div class="input-group mb-5">
<input @bind="variable.Key" type="text" class="form-control disabled" disabled="">
<div class="row mb-3">
@foreach (var variable in vars)
{
<div class="col">
<div class="card card-body">
<label class="form-label">
<TL>Name</TL>
</label>
<div class="input-group mb-5">
<input @bind="variable.Key" type="text" class="form-control disabled" disabled="">
</div>
<label class="form-label">
<TL>Value</TL>
</label>
<div class="input-group mb-5">
<input @bind="variable.Value" type="text" class="form-control">
</div>
</div>
</div>
<label class="form-label"><TL>Value</TL></label>
<div class="input-group mb-5">
<input @bind="variable.Value" type="text" class="form-control">
</div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
<div class="row">
<div class="card card-body">
<div class="btn-group">
<a class="btn btn-primary" href="/admin/servers">
<TL>Back</TL>
<div class="d-flex justify-content-end">
<a href="/admin/servers" class="btn btn-danger me-3">
<TL>Cancel</TL>
</a>
<WButton Text="@(SmartTranslateService.Translate("Create"))"
WorkingText="@(SmartTranslateService.Translate("Creating"))"
@ -201,7 +209,7 @@
RebuildVariables();
InvokeAsync(StateHasChanged);
InvokeAsync(StateHasChanged);
}
}
@ -253,7 +261,7 @@
User = Users.FirstOrDefault();
Image = Images.FirstOrDefault();
RebuildVariables();
if (Image != null)

View file

@ -0,0 +1,25 @@
@page "/admin/system/discordbot"
@using Moonlight.App.Services.DiscordBot
@using Moonlight.Shared.Components.Navigations
@inject DiscordBotService DiscordBotService
<OnlyAdmin>
<AdminSystemNavigation Index="6"/>
<div class="mt-3 card card-body">
<WButton Text="Register commands"
WorkingText="Working"
CssClasses="btn-primary"
OnClick="RegisterCommands">
</WButton>
</div>
</OnlyAdmin>
@code
{
private async Task RegisterCommands()
{
}
}

View file

@ -14,7 +14,7 @@
<div class="card">
<div class="card-body">
<LazyLoader Load="Load">
<Table TableItem="LogEntry" Items="LogEntries" PageSize="25" TableHeadClass="border-bottom border-gray-200 fs-6 text-gray-600 fw-bold bg-light bg-opacity-75">
<Table TableItem="LogEntry" Items="LogEntries" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="LogEntry" Title="@(SmartTranslateService.Translate("Time"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="false"></Column>
<Column TableItem="LogEntry" Title="@(SmartTranslateService.Translate("Log level"))" Field="@(x => x.Level)" Sortable="true" Filterable="false"></Column>
<Column TableItem="LogEntry" Title="@(SmartTranslateService.Translate("Log message"))" Field="@(x => x.Message)" Sortable="false" Filterable="true"></Column>

View file

@ -1,7 +1,66 @@
@page "/admin/users"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities
@using BlazorTable
@using Moonlight.App.Services
@inject UserRepository UserRepository
@inject SmartTranslateService SmartTranslateService
<OnlyAdmin>
<AdminSessionNavigation Index="0" />
</OnlyAdmin>
<AdminSessionNavigation Index="0"/>
<div class="card">
<LazyLoader Load="Load">
<div class="card-header border-0 pt-5">
<h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold fs-3 mb-1">
<TL>Users</TL>
</span>
</h3>
<div class="card-toolbar">
<a href="/admin/users/new" class="btn btn-sm btn-light-success">
<i class="bx bx-user-plus"></i>
<TL>New user</TL>
</a>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<Table TableItem="User" Items="Users" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Email"))" Field="@(x => x.Email)" Sortable="true" Filterable="true">
<Template>
<a href="/admin/users/view/@(context.Id)">@(context.Email)</a>
</Template>
</Column>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("First name"))" Field="@(x => x.FirstName)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Last name"))" Field="@(x => x.LastName)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Created at"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="true"/>
<Column TableItem="User" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false">
<Template>
<a href="/admin/users/@(context.Id)/edit">
<TL>Manage</TL>
</a>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</div>
</LazyLoader>
</div>
</OnlyAdmin>
@code
{
private User[] Users;
private async Task Load(LazyLoader lazyLoader)
{
Users = UserRepository.Get().ToArray();
await InvokeAsync(StateHasChanged);
}
}

View file

@ -0,0 +1,66 @@
@page "/admin/users/new"
@using Moonlight.App.Database.Entities
@using Moonlight.App.Services
@using Moonlight.App.Services.Interop
@inject SmartTranslateService SmartTranslateService
@inject NavigationManager NavigationManager
@inject ToastService ToastService
@inject UserService UserService
<OnlyAdmin>
<div class="row mb-5">
<div class="card card-body p-10">
<label class="form-label">
<TL>First name</TL>
</label>
<div class="input-group mb-5">
<input @bind="User.FirstName" type="text" class="form-control">
</div>
<label class="form-label">
<TL>Last name</TL>
</label>
<div class="input-group mb-5">
<input @bind="User.LastName" type="text" class="form-control">
</div>
<label class="form-label">
<TL>Email</TL>
</label>
<div class="input-group mb-5">
<input @bind="User.Email" type="email" class="form-control">
</div>
<label class="form-label">
<TL>Password</TL>
</label>
<div class="input-group mb-5">
<input @bind="User.Password" type="password" class="form-control">
</div>
</div>
</div>
<div class="row">
<div class="card card-body">
<div class="d-flex justify-content-end">
<a href="/admin/users" class="btn btn-danger me-3">
<TL>Cancel</TL>
</a>
<WButton Text="@(SmartTranslateService.Translate("Create"))"
WorkingText="@(SmartTranslateService.Translate("Creating"))"
CssClasses="btn-success"
OnClick="Create">
</WButton>
</div>
</div>
</div>
</OnlyAdmin>
@code
{
private User User = new();
private async Task Create()
{
await UserService.Register(User.Email, User.Password, User.FirstName, User.LastName);
await ToastService.Success(SmartTranslateService.Translate("User successfully created"));
NavigationManager.NavigateTo("/admin/users");
}
}

View file

@ -16,58 +16,77 @@
<OnlyAdmin>
<AdminSessionNavigation Index="1"/>
<div class="card card-body">
<div class="card">
<LazyLoader Load="Load">
<button class="btn btn-primary mb-3" @onclick="Refresh">
<TL>Refresh</TL>
</button>
<button class="btn btn-warning mb-3" @onclick="MessageAll">
<TL>Send a message to all users</TL>
</button>
@if (AllSessions == null)
{
<div class="alert alert-info">
<span class="spinner-border spinner-border-sm align-middle me-2"></span>
<TL>Loading sessions</TL>
<div class="card-header border-0 pt-5">
<h3 class="card-title align-items-start flex-column">
<span class="card-label fw-bold fs-3 mb-1">
<TL>Sessions</TL>
</span>
</h3>
<div class="card-toolbar">
<button class="btn btn-sm btn-primary me-3" @onclick="Refresh">
<i class="bx bx-revision"></i>
<TL>Refresh</TL>
</button>
<button class="btn btn-sm btn-warning" @onclick="MessageAll">
<i class="bx bx-message-square-dots"></i>
<TL>Send a message to all users</TL>
</button>
</div>
}
else
{
<Table TableItem="Session" Items="AllSessions" PageSize="25" TableHeadClass="border-bottom border-gray-200 fs-6 text-gray-600 fw-bold bg-light bg-opacity-75">
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Email"))" Field="@(x => x.User.Id)" Sortable="true" Filterable="true" Width="20%">
<Template>
<span>@(context.User == null ? "" : context.User.Email)</span>
</Template>
</Column>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("IP"))" Field="@(x => x.Ip)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("URL"))" Field="@(x => x.Url)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Device"))" Field="@(x => x.Device)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Time"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="true" Width="10%">
<Template>
@{
var time = Formatter.FormatUptime((DateTime.Now - context.CreatedAt).TotalMilliseconds);
}
<span>@(time)</span>
</Template>
</Column>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Actions"))" Field="@(x => x.Ip)" Sortable="false" Filterable="false" Width="10%">
<Template>
<button @onclick="() => Navigate(context)" class="btn btn-primary">
<TL>Change url</TL>
</button>
</Template>
</Column>
<Column TableItem="Session" Title="" Field="@(x => x.Ip)" Sortable="false" Filterable="false" Width="10%">
<Template>
<button @onclick="() => Message(context)" class="btn btn-warning">
<TL>Message</TL>
</button>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
}
</div>
<div class="card-body pt-0">
@if (AllSessions == null)
{
<div class="alert alert-info">
<span class="spinner-border spinner-border-sm align-middle me-2"></span>
<TL>Loading sessions</TL>
</div>
}
else
{
<Table TableItem="Session" Items="AllSessions" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Email"))" Field="@(x => x.User.Id)" Sortable="true" Filterable="true" Width="20%">
<Template>
@if (context.User == null)
{
<TL>Guest</TL>
}
else
{
<a href="/admin/users/view/@(context.User.Id)">@(context.User.Email)</a>
}
</Template>
</Column>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("IP"))" Field="@(x => x.Ip)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("URL"))" Field="@(x => x.Url)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Device"))" Field="@(x => x.Device)" Sortable="true" Filterable="true" Width="10%"/>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Time"))" Field="@(x => x.CreatedAt)" Sortable="true" Filterable="true" Width="10%">
<Template>
@{
var time = Formatter.FormatUptime((DateTime.Now - context.CreatedAt).TotalMilliseconds);
}
<span>@(time)</span>
</Template>
</Column>
<Column TableItem="Session" Title="@(SmartTranslateService.Translate("Actions"))" Field="@(x => x.Ip)" Sortable="false" Filterable="false" Width="10%">
<Template>
<button @onclick="() => Navigate(context)" class="btn btn-sm btn-primary">
<TL>Change url</TL>
</button>
</Template>
</Column>
<Column TableItem="Session" Title="" Field="@(x => x.Ip)" Sortable="false" Filterable="false" Width="10%">
<Template>
<button @onclick="() => Message(context)" class="btn btn-sm btn-warning">
<TL>Message</TL>
</button>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
}
</div>
</LazyLoader>
</div>
</OnlyAdmin>
@ -79,7 +98,7 @@
private Task Load(LazyLoader arg)
{
AllSessions = SessionService.GetAll();
Task.Run(async () =>
{
while (true)
@ -96,7 +115,7 @@
}
}
});
return Task.CompletedTask;
}
@ -109,8 +128,8 @@
private async Task Navigate(Session session)
{
var url = await AlertService.Text("URL", SmartTranslateService.Translate("Enter url"), "");
if(url == null)
if (url == null)
return;
if (url == "")

View file

@ -0,0 +1,261 @@
@page "/admin/users/view/{Id:int}"
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Repositories
@using Moonlight.App.Repositories.Servers
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Repositories.Domains
@inject UserRepository UserRepository
@inject ServerRepository ServerRepository
@inject DomainRepository DomainRepository
<OnlyAdmin>
<LazyLoader Load="Load">
@if (User == null)
{
<div class="alert alert-danger">
<TL>No user with this id found</TL>
</div>
}
else
{
<div class="row">
<div class="col-md-4">
<div class="card card-body mb-5">
<div class="d-flex flex-column align-items-center text-center">
<img src="/api/moonlight/avatar/@(User.Id)" class="rounded-circle" alt="Profile picture" width="150">
</div>
</div>
<div class="card card-body mb-5">
<div class="btn-group">
<a class="btn btn-primary" href="/admin/users/edit/@(User.Id)"><TL>Edit</TL></a>
<a class="btn btn-secondary" href="/admin/users"><TL>Back to list</TL></a>
<a class="btn btn-primary" href="/admin/support/view/@(User.Id)"><TL>Open support</TL></a>
</div>
</div>
<div class="card card-xl-stretch mb-5">
<div class="card-header border-0">
<h3 class="card-title fw-bold text-dark">
<TL>Servers</TL>
</h3>
</div>
<div class="card-body pt-2">
@foreach (var server in Servers)
{
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<a href="/server/@(server.Uuid)" class="fs-6">@(server.Name) - @(server.Image.Name)</a>
</div>
</div>
if (server != Servers.Last())
{
<div class="separator my-4"></div>
}
}
</div>
</div>
<div class="card card-xl-stretch">
<div class="card-header border-0">
<h3 class="card-title fw-bold text-dark">
<TL>Domains</TL>
</h3>
</div>
<div class="card-body pt-2">
@foreach (var domain in Domains)
{
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<a href="/domain/@(domain.Id)" class="fs-6">@(domain.Name).@(domain.SharedDomain.Name)</a>
</div>
</div>
if (domain != Domains.Last())
{
<div class="separator my-4"></div>
}
}
</div>
</div>
</div>
<div class="col-md-8">
<div class="card mb-3">
<div class="card-body fs-6">
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>First name</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.FirstName)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Last name</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.LastName)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Email</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.Email)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Address</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.Address)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>City</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.City)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>State</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.State)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Country</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.Country)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Admin</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">
@if (User.Admin)
{
<span>✅</span>
}
else
{
<span>❌</span>
}
</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Status</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.Status)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Totp</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.TotpEnabled)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Discord</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(User.DiscordUsername)#@(User.DiscordDiscriminator)</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Subscription</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">
@if (User.Subscription == null)
{
<span>
<TL>None</TL>
</span>
}
else
{
<span>@(User.Subscription.Name)</span>
}
</span>
</div>
</div>
<div class="separator my-4"></div>
<div class="row">
<label class="col-lg-4 fw-semibold text-muted">
<TL>Created at</TL>
</label>
<div class="col-lg-8">
<span class="fw-bold fs-6 text-gray-800">@(Formatter.FormatDate(User.CreatedAt))</span>
</div>
</div>
</div>
</div>
</div>
</div>
}
</LazyLoader>
</OnlyAdmin>
@code
{
[Parameter]
public int Id { get; set; }
private User? User;
private Server[] Servers;
private Domain[] Domains;
private Task Load(LazyLoader arg)
{
User = UserRepository.Get().FirstOrDefault(x => x.Id == Id);
if (User != null)
{
Servers = ServerRepository
.Get()
.Include(x => x.Owner)
.Include(x => x.Image)
.Where(x => x.Owner.Id == User.Id)
.ToArray();
Domains = DomainRepository
.Get()
.Include(x => x.SharedDomain)
.Include(x => x.Owner)
.Where(x => x.Owner.Id == User.Id)
.ToArray();
}
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,2 @@
@page "/test"

View file

@ -306,3 +306,27 @@ WinSCP cannot be launched here;WinSCP cannot be launched here
Create a new folder;Create a new folder
Enter a name;Enter a name
File upload complete;File upload complete
New server;New server
Sessions;Sessions
New user;New user
Created at;Created at
Mail template not found;Mail template not found
Missing admin permissions. This attempt has been logged ;)
Address;Address
City;City
State;State
Country;Country
Totp;Totp
Discord;Discord
Subscription;Subscription
None;None
No user with this id found;No user with this id found
Back to list;Back to list
New domain;New domain
Reset password;Reset password
Password reset;Password reset
Reset the password of your account;Reset the password of your account
Wrong here?;Wrong here?
A user with this email can not be found;A user with this email can not be found
Passwort reset successfull. Check your mail;Passwort reset successfull. Check your mail
Discord bot;Discord bot

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Moonlight password reset</title>
</head>
<body>
<div style="background-color:#ffffff; padding: 45px 0 34px 0; border-radius: 24px; margin:40px auto; max-width: 600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" height="auto"
style="border-collapse:collapse">
<tbody>
<tr>
<td align="center" valign="center" style="text-align:center; padding-bottom: 10px">
<div style="text-align:center; margin:0 15px 34px 15px">
<div style="margin-bottom: 10px">
<a href="https://endelon-hosting.de" rel="noopener" target="_blank">
<img alt="Logo" src="https://moonlight.endelon-hosting.de/assets/media/logo/MoonFullText.png" style="height: 35px">
</a>
</div>
<div style="font-size: 14px; font-weight: 500; margin-bottom: 27px; font-family:Arial,Helvetica,sans-serif;">
<p style="margin-bottom:9px; color:#181C32; font-size: 22px; font-weight:700">Hey {{FirstName}}, your password has been resetted</p>
<p style="margin-bottom:2px; color:#7E8299">Your new password is: <b>{{Password}}</b></p>
<p style="margin-bottom:2px; color:#7E8299">If this was not you please contact us. Also here is the data we collected.</p>
<p style="margin-bottom:2px; color:#7E8299">IP: {{Ip}}</p>
<p style="margin-bottom:2px; color:#7E8299">Device: {{Device}}</p>
<p style="margin-bottom:2px; color:#7E8299">Location: {{Location}}</p>
</div>
<a href="https://moonlight.endelon-hosting.de" target="_blank"
style="background-color:#50cd89; border-radius:6px;display:inline-block; padding:11px 19px; color: #FFFFFF; font-size: 14px; font-weight:500;">Open Moonlight
</a>
</div>
</td>
</tr>
<tr>
<td align="center" valign="center"
style="font-size: 13px; text-align:center; padding: 0 10px 10px 10px; font-weight: 500; color: #A1A5B7; font-family:Arial,Helvetica,sans-serif">
<p style="color:#181C32; font-size: 16px; font-weight: 600; margin-bottom:9px">You need help?</p>
<p style="margin-bottom:2px">We are happy to help!</p>
<p style="margin-bottom:4px">More information at
<a href="https://endelon.link/support" rel="noopener" target="_blank" style="font-weight: 600">endelon.link/support</a>.
</p>
</td>
</tr>
<tr>
<td align="center" valign="center"
style="font-size: 13px; padding:0 15px; text-align:center; font-weight: 500; color: #A1A5B7;font-family:Arial,Helvetica,sans-serif">
<p>Copyright 2022 Endelon Hosting </p>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome</title>
</head>
<body>
<div style="background-color:#ffffff; padding: 45px 0 34px 0; border-radius: 24px; margin:40px auto; max-width: 600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" height="auto"
style="border-collapse:collapse">
<tbody>
<tr>
<td align="center" valign="center" style="text-align:center; padding-bottom: 10px">
<div style="text-align:center; margin:0 15px 34px 15px">
<div style="margin-bottom: 10px">
<a href="https://endelon-hosting.de" rel="noopener" target="_blank">
<img alt="Logo" src="https://moonlight.endelon-hosting.de/assets/media/logo/MoonFullText.png" style="height: 35px">
</a>
</div>
<div style="font-size: 14px; font-weight: 500; margin-bottom: 27px; font-family:Arial,Helvetica,sans-serif;">
<p style="margin-bottom:9px; color:#181C32; font-size: 22px; font-weight:700">Hey {{FirstName}}, welcome to moonlight</p>
<p style="margin-bottom:2px; color:#7E8299">We are happy to welcome you in ;)</p>
</div>
<a href="https://moonlight.endelon-hosting.de" target="_blank"
style="background-color:#50cd89; border-radius:6px;display:inline-block; padding:11px 19px; color: #FFFFFF; font-size: 14px; font-weight:500;">Open Moonlight
</a>
</div>
</td>
</tr>
<tr>
<td align="center" valign="center"
style="font-size: 13px; text-align:center; padding: 0 10px 10px 10px; font-weight: 500; color: #A1A5B7; font-family:Arial,Helvetica,sans-serif">
<p style="color:#181C32; font-size: 16px; font-weight: 600; margin-bottom:9px">You need help?</p>
<p style="margin-bottom:2px">We are happy to help!</p>
<p style="margin-bottom:4px">More information at
<a href="https://endelon.link/support" rel="noopener" target="_blank" style="font-weight: 600">endelon.link/support</a>.
</p>
</td>
</tr>
<tr>
<td align="center" valign="center"
style="font-size: 13px; padding:0 15px; text-align:center; font-weight: 500; color: #A1A5B7;font-family:Arial,Helvetica,sans-serif">
<p>Copyright 2022 Endelon Hosting </p>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>