Add admin project, add separate admin and user identity tables (#113)

This commit is contained in:
Leendert de Borst
2024-07-20 12:59:03 +02:00
parent b165969598
commit 902147cbf6
159 changed files with 36418 additions and 214 deletions

View File

@@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.SmtpService", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.IntegrationTests", "src\Tests\AliasVault.IntegrationTests\AliasVault.IntegrationTests.csproj", "{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AliasVault.Admin", "src\AliasVault.Admin\AliasVault.Admin.csproj", "{7D21383B-7B47-4CE6-92D9-BB2A99AE3E50}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -103,6 +105,10 @@ Global
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C7C8DE9-5F2A-43DB-A25E-33319E80A509}.Release|Any CPU.Build.0 = Release|Any CPU
{7D21383B-7B47-4CE6-92D9-BB2A99AE3E50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D21383B-7B47-4CE6-92D9-BB2A99AE3E50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D21383B-7B47-4CE6-92D9-BB2A99AE3E50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D21383B-7B47-4CE6-92D9-BB2A99AE3E50}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

Binary file not shown.

View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

View File

@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\net8.0\AliasVault.xml</DocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DocumentationFile>bin\Release\net8.0\AliasVault.xml</DocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\LICENSE.md">
<Link>LICENSE.md</Link>
</Content>
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="Components\Pages\Aliases\AddEdit.razor" />
<_ContentIncludedByDefault Remove="Components\Pages\Aliases\Alias.razor" />
<_ContentIncludedByDefault Remove="Components\Pages\Aliases\Delete.razor" />
<_ContentIncludedByDefault Remove="Components\Pages\Aliases\View.razor" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
@page "/user/email-confirm"
@using AliasServerDb
@using Microsoft.AspNetCore.Mvc.TagHelpers
@attribute [IgnoreAntiforgeryToken]
@inject SignInManager<AdminUser> SignInManager
@inject UserManager<AdminUser> UserManager
@if (Success)
{
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Email address confirmed!</h2>
<p class="mb-5">
Your email address has been successfully confirmed. You can now log in to your account.
</p>
<a href="/" class="btn btn-primary"><span class="material-symbols-rounded align-middle me-2">arrow_back</span>Log in</a>
}
else
{
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Confirm email address</h2>
<div class="alert alert-danger" role="alert">
@Error
</div>
<a href="/" class="btn btn-primary"><span class="material-symbols-rounded align-middle me-2">arrow_back</span>Return to login</a>
}
@functions {
public bool Success { get; set; }
public string Message { get; set; } = "";
public string Error { get; set; } = "";
public async Task<IActionResult> OnGet()
{
var userId = Request.Query["userId"];
var code = Request.Query["code"];
var user = await UserManager.FindByIdAsync(userId);
if (user == null)
{
// Error: User not found
Error = "Invalid URL";
return Page();
}
var result = await UserManager.ConfirmEmailAsync(user, code);
if (result.Succeeded)
{
// Email confirmed!
Success = true;
// Check if user is logged in already, if so, redirect to homepage
if (SignInManager.IsSignedIn(User))
{
return Redirect("~/?successMessage=EmailConfirmed");
}
else
{
// Show success message to tell user they can now log in
return Redirect("/user/login?emailConfirmed=true");
}
}
else
{
// Error: Invalid token
// Convert errors to ErrorString
Error = "Custom errors: \n";
result.Errors.ToList().ForEach(e => Error += e.Description + "\n");
}
return Page();
}
public async Task<IActionResult> OnPost()
{
if (SignInManager.IsSignedIn(User))
{
await SignInManager.SignOutAsync();
}
return Redirect("~/");
}
}

View File

@@ -0,0 +1,29 @@
@page "/user/forgot-password"
@attribute [IgnoreAntiforgeryToken]
@model ForgotPasswordModel
@{
ViewData["Title"] = "Forgot password";
}
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Forgot password</h2>
<p class="text-white-50 mb-4">Enter your email to get a password reset link.</p>
<form id="account" method="post" class=" z-index-1 position-relative needs-validation" novalidate="">
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control"></input>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<p class="small mb-5 pb-lg-2"><a class="text-white-50" href="/user/login">Remember your password? Sign in.</a></p>
<button data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-light btn-lg px-5" type="submit">Send password reset email</button>
<div class="d-flex justify-content-center text-center mt-4 pt-1">
<a href="#!" class="text-white"><i class="fab fa-facebook-f fa-lg"></i></a>
<a href="#!" class="text-white"><i class="fab fa-twitter fa-lg mx-4 px-2"></i></a>
<a href="#!" class="text-white"><i class="fab fa-google fa-lg"></i></a>
</div>
</form>

View File

@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using System.Web;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
//using SendgridEmail;
namespace AliasVault.Areas.User.Pages;
public class ForgotPasswordModel : PageModel
{
private readonly UserManager<AdminUser> _userManager;
private readonly IConfiguration _configuration;
//private readonly EmailService _emailService;
public ForgotPasswordModel(SignInManager<AdminUser> signInManager, UserManager<AdminUser> userManager, IConfiguration configuration
//EmailService emailService
)
{
_userManager = userManager;
_configuration = configuration;
//_emailService = emailService;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null)
{
// User with the given email not found.
// For security reasons, don't reveal this information.
return LocalRedirect("/user/login?passwordReset=true");
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
string encodedToken = HttpUtility.UrlEncode(token);
// Send the token to the user's email
string scheme = HttpContext.Request.Scheme; // "http" or "https"
string host = HttpContext.Request.Host.Value; // the host name and port
string baseUrl = $"{scheme}://{host}/";
//await _emailService.SendUserPasswordForgotMailAsync(user, "UserEmailConfirm", baseUrl, encodedToken, _configuration["SendGridApiKey"] ?? "");
return LocalRedirect("/user/login?passwordReset=true");
// Add the errors from the result to the model state
/*foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}*/
}
return Page();
}
public class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,49 @@
@page "/user/login"
@attribute [IgnoreAntiforgeryToken]
@using Microsoft.IdentityModel.Tokens
@model LoginModel
@{
ViewData["Title"] = "Login";
}
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Login</h2>
<p class="text-white-50 mb-4">Please enter your login and password.</p>
<form id="account" method="post" class=" z-index-1 position-relative needs-validation" novalidate="">
@if (!Model.SuccessMessage.IsNullOrEmpty())
{
<div class="alert alert-success" role="alert">
@Model.SuccessMessage
</div>
}
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control"></input>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control"></input>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<p class="small mb-5 pb-lg-2"><a class="text-white-50" href="/user/forgot-password">Forgot password?</a></p>
<button data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-light btn-lg px-5" type="submit">Login</button>
<div class="d-flex justify-content-center text-center mt-4 pt-1">
<a href="#!" class="text-white"><i class="fab fa-facebook-f fa-lg"></i></a>
<a href="#!" class="text-white"><i class="fab fa-twitter fa-lg mx-4 px-2"></i></a>
<a href="#!" class="text-white"><i class="fab fa-google fa-lg"></i></a>
</div>
</form>
<div>
<p class="mb-0">Don't have an account? <a href="/user/register" class="text-white-50 fw-bold">Sign Up</a>
</p>
</div>

View File

@@ -0,0 +1,115 @@
using System.ComponentModel.DataAnnotations;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.IdentityModel.Tokens;
using AliasVault.Admin.Services;
namespace AliasVault.Areas.User.Pages;
public class LoginModel : PageModel
{
private readonly SignInManager<AdminUser> _signInManager;
private readonly UserService _userService;
public LoginModel(SignInManager<AdminUser> signInManager, UserService userService)
{
_signInManager = signInManager;
_userService = userService;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public string? ReturnUrl { get; set; }
public string SuccessMessage { get; set; } = "";
public void OnGet()
{
if (!Request.Query["returnUrl"].IsNullOrEmpty())
{
ReturnUrl = Request.Query["returnUrl"].ToString();
}
if (!Request.Query["emailConfirmed"].IsNullOrEmpty())
{
if (Request.Query["emailConfirmed"] == "true")
{
SuccessMessage = "Your email address has been confirmed. You can now log in.";
}
}
else if (!Request.Query["passwordReset"].IsNullOrEmpty())
{
if (Request.Query["passwordReset"] == "true")
{
SuccessMessage = "Check your mailbox for the password reset link.";
}
}
else if (!Request.Query["passwordChanged"].IsNullOrEmpty())
{
if (Request.Query["passwordChanged"] == "true")
{
SuccessMessage = "Your password has been changed. You can now log in.";
}
}
// Check if the user is already logged in
if (_signInManager.IsSignedIn(User))
{
// Redirect to home page
Response.Redirect(GetInAppRedirectUrl());
}
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var result = await _signInManager.PasswordSignInAsync(Input.Email ?? "", Input.Password ?? "", isPersistent: false,
lockoutOnFailure: false);
if (Request.Query["returnUrl"] != String.Empty)
{
ReturnUrl = Request.Query["returnUrl"].ToString();
}
if (result.Succeeded)
{
return LocalRedirect(GetInAppRedirectUrl());
}
else if (result.IsNotAllowed)
{
// The account hasn't been confirmed
ModelState.AddModelError(string.Empty, "You must confirm your email address before you can sign in.");
}
else
{
// Add error to the model state
ModelState.AddModelError(string.Empty, "The login attempt failed. Check if the email and password are correct and try again. If you don't remember your password, you can reset it.");
}
}
return Page();
}
public string GetInAppRedirectUrl()
{
if (ReturnUrl != null && Url.IsLocalUrl(ReturnUrl))
{
return ReturnUrl;
}
return "/";
}
public class InputModel
{
[Required]
[EmailAddress]
public string? Email { get; set; }
[Required]
[DataType(DataType.Password)]
public string? Password { get; set; }
}
}

View File

@@ -0,0 +1,27 @@
@page "/user/logout"
@using AliasServerDb
@attribute [IgnoreAntiforgeryToken]
@inject SignInManager<AdminUser> SignInManager
@functions {
public async Task<IActionResult> OnGet()
{
if (SignInManager.IsSignedIn(User))
{
await SignInManager.SignOutAsync();
}
return Redirect("~/");
}
public async Task<IActionResult> OnPost()
{
if (SignInManager.IsSignedIn(User))
{
await SignInManager.SignOutAsync();
}
return Redirect("~/");
}
}

View File

@@ -0,0 +1,55 @@
@page "/user/register"
@attribute [IgnoreAntiforgeryToken]
@inject IConfiguration Configuration
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model AliasVault.Areas.User.Pages.RegisterModel
@{
ViewData["Title"] = "Register";
}
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Create account</h2>
<p class="text-white-50 mb-4">To get started, please register your account.</p>
<form id="account" method="post" class=" z-index-1 position-relative needs-validation" novalidate="">
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Email" class="form-label"></label>
<input asp-for="Input.Email" class="form-control"></input>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control"></input>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.PasswordConfirm" class="form-label"></label>
<input asp-for="Input.PasswordConfirm" class="form-control"></input>
<span asp-validation-for="Input.PasswordConfirm" class="text-danger"></span>
</div>
<div class="form-check mb-3">
<div>
<input asp-for="Input.AcceptTerms" class="form-check-input"/>
<label for="Input_AcceptTerms" class="form-label">I agree with the <a href="@(Configuration["WebshopBaseUrl"])/terms-conditions" target="_blank">terms and conditions</a></label>
</div>
<span asp-validation-for="Input.AcceptTerms" class="text-danger"></span>
</div>
<button data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-light btn-lg px-5" type="submit">Register</button>
<div class="d-flex justify-content-center text-center mt-4 pt-1">
<a href="#!" class="text-white"><i class="fab fa-facebook-f fa-lg"></i></a>
<a href="#!" class="text-white"><i class="fab fa-twitter fa-lg mx-4 px-2"></i></a>
<a href="#!" class="text-white"><i class="fab fa-google fa-lg"></i></a>
</div>
</form>
<div>
<p class="mb-0">Already have an account? <a href="/user/login" class="text-white-50 fw-bold">Sign In</a>
</p>
</div>

View File

@@ -0,0 +1,78 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AliasVault.Areas.User.Pages;
public class RegisterModel : PageModel
{
private readonly SignInManager<AdminUser> _signInManager;
private readonly UserManager<AdminUser> _userManager;
public RegisterModel(SignInManager<AdminUser> signInManager, UserManager<AdminUser> userManager)
{
_signInManager = signInManager;
_userManager = userManager;
}
[BindProperty] public InputModel Input { get; set; } = new();
public void OnGet()
{
// Check if the user is already logged in
if (_signInManager.IsSignedIn(User))
{
// Redirect to user login page (which will redirect to home page)
Response.Redirect("/user/login");
}
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var identity = new AdminUser { UserName = Input.Email, Email = Input.Email };
var result = await _userManager.CreateAsync(identity, Input.Password);
if (result.Succeeded)
{
await _signInManager.SignInAsync(identity, isPersistent: false);
return LocalRedirect("/");
}
// Add the errors from the result to the model state
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
return Page();
}
public class InputModel
{
[Required] [EmailAddress] public string Email { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
//[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$", ErrorMessage = "Password must contain at least one uppercase letter, one lowercase letter, and one number.")]
public string Password { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
[DisplayName("Confirm password")]
[Compare("Password", ErrorMessage = "Password and confirmation password do not match.")]
public string PasswordConfirm { get; set; } = string.Empty;
[Required]
[DisplayName("I agree with the terms and conditions")]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must accept the terms and conditions.")]
public bool AcceptTerms { get; set; }
}
}

View File

@@ -0,0 +1,32 @@
@page "/user/reset-password"
@attribute [IgnoreAntiforgeryToken]
@model ResetPasswordModel
@{
ViewData["Title"] = "Reset password";
}
<partial name="_Logo" />
<h2 class="fw-bold mb-2 text-uppercase">Enter new password</h2>
<p class="text-white-50 mb-4">Enter a new password for your account.</p>
<form id="account" method="post" class=" z-index-1 position-relative needs-validation" novalidate="">
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control"></input>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div data-mdb-input-init class="form-outline form-white mb-4">
<label asp-for="Input.PasswordConfirm" class="form-label"></label>
<input asp-for="Input.PasswordConfirm" class="form-control"></input>
<span asp-validation-for="Input.PasswordConfirm" class="text-danger"></span>
</div>
<button data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-light btn-lg px-5" type="submit">Change Password</button>
</form>
<div>
<p class="mb-0">Remember your password? <a href="/user/login" class="text-white-50 fw-bold">Sign In</a>
</p>
</div>

View File

@@ -0,0 +1,80 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AliasVault.Areas.User.Pages;
public class ResetPasswordModel : PageModel
{
private readonly UserManager<AdminUser> _userManager;
public ResetPasswordModel(UserManager<AdminUser> userManager)
{
_userManager = userManager;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public async Task<IActionResult> OnGet()
{
var userId = Request.Query["userId"];
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
// Error: User not found
return LocalRedirect("/user/login");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var userId = Request.Query["userId"];
var code = Request.Query["code"];
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
// Error: User not found
return LocalRedirect("/user/login");
}
var result = await _userManager.ResetPasswordAsync(user, code, Input.Password);
if (result.Succeeded)
{
return LocalRedirect("/user/login?passwordChanged=true");
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
return Page();
}
public class InputModel
{
[Required]
[DisplayName("Enter new password")]
[DataType(DataType.Password)]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")]
//[RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$", ErrorMessage = "Password must contain at least one uppercase letter, one lowercase letter, and one number.")]
public string Password { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
[DisplayName("Confirm new password")]
[Compare("Password", ErrorMessage = "Password and confirmation password do not match.")]
public string PasswordConfirm { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,2 @@
<img src="horizontal-logo-cropped.png" alt="AliasesVault" class="img-fluid" style="max-width: 330px;"/>
<hr />

View File

@@ -0,0 +1,62 @@
@namespace AliasVault.Areas.User
@using AliasVault.Admin.Services;
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject VersionedContentService VersionService
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="~/"/>
<link rel="stylesheet" href="@VersionService.GetVersionedPath("bootstrap/bootstrap.min.css")"/>
<link href="@VersionService.GetVersionedPath("app.css")" rel="stylesheet"/>
<link href="AliasVault.styles.css" rel="stylesheet"/>
<link rel="icon" type="image/png" href="favicon.png"/>
<!--Google web fonts-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital@0;1&family=Open+Sans:wght@300..800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0"/>
<title>AliasesVault</title>
</head>
<body>
<!--////////////////// PreLoader Start//////////////////////-->
<div class="loader bg-gradient-primary text-white" style="display: none;">
<!--Placeholder animated layout for preloader-->
<div class="d-flex flex-column flex-root">
<div class="page d-flex flex-row flex-column-fluid">
<div class="page-content ps-0 ms-0 d-flex flex-column flex-row-fluid">
<div class="content flex-column p-4 pb-0 d-flex justify-content-center align-items-center flex-column-fluid position-relative">
<div class="w-100 h-100 position-relative d-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-loader anim-spinner me-2">
<line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
</svg>
<div>
<span>Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--////////////////// /.PreLoader END//////////////////////-->
<section class="vh-100 gradient-custom">
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
<div class="card bg-dark text-white" style="border-radius: 1rem;">
<div class="card-body p-5 text-center">
@RenderBody()
</div>
</div>
</div>
</div>
</div>
</section>
<script src="_framework/blazor.server.js"></script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
@using AliasVault
@using AliasVault.Areas.User
@using AliasVault.Areas.User.Pages
@using AliasVault.Areas.User.Pages.Shared
@using Microsoft.AspNetCore.Identity
@namespace AliasVault.Areas.User
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,3 @@
@{
Layout = "_IdentityLayout.cshtml";
}

View File

@@ -0,0 +1,15 @@
@using AliasVault.Admin.Components.Layout
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<PageNotFound />
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>

View File

@@ -0,0 +1,37 @@
using AliasVault.Admin.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AliasVault.Components.Layouts.Base;
/// <summary>
/// Checks whether the current event has the Faqs plugin enabled. If not, user is redirected
/// to the plugin disabled page.
/// </summary>
public class BaseInAppLayout : LayoutComponentBase
{
[Inject]
public JsInvokeService JsInvokeService { get; set; } = null!;
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
[Inject]
public UserService UserService { get; set; } = null!;
protected bool AccessCheckCompleted;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Call the GenericAccessCheckAsync method and halt execution if a redirect is required.
AccessCheckCompleted = await AccessCheckService.GenericAccessCheckAsync(NavigationManager, UserService);
StateHasChanged();
// Active main theme init logic (darkmode, menu toggle)
await JsInvokeService.RetryInvokeAsync("mainThemeInit", TimeSpan.FromMilliseconds(200), 5);
}
}
}

View File

@@ -0,0 +1,24 @@
@inherits AliasVault.Components.Layouts.Base.BaseInAppLayout
@if (!AccessCheckCompleted)
{
<div class="mt-10">
<FullPageLoadingAnimation />
</div>
}
else
{
<TopMenu />
<div class="content-wrapper">
<!-- Content -->
<div class="container-xxl flex-grow-1 container-p-y">
@Body
</div>
<!-- / Content -->
<div class="content-backdrop fade"></div>
</div>
}

View File

@@ -0,0 +1,96 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,29 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">AliasVault</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler"/>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,104 @@
@using AliasVault.Admin.Services
@inject UserService UserService
<nav class="layout-navbar container-xxl navbar navbar-expand-xl navbar-detached align-items-center bg-navbar-theme" id="layout-navbar">
<a href="/"><img src="horizontal-logo-cropped.png" alt="Logo" class="d-block d-xl-none" style="width: 170px;"></a>
<div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
<!-- Search -->
<div class="navbar-nav align-items-center">
<div class="nav-item d-flex align-items-center">
<i class="bx bx-search fs-4 lh-0"></i>
<input type="text" class="form-control mt-3 border-0 shadow-none" placeholder="Search..." aria-label="Search...">
</div>
</div>
<!-- /Search -->
<ul class="navbar-nav flex-row align-items-center ms-auto">
<!-- Place this tag where you want the button to render. -->
<li class="nav-item lh-1 me-3">
<span></span>
</li>
<!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="javascript:void(0);" data-bs-toggle="dropdown">
<div class="avatar avatar-online">
<img src="../assets/img/avatars/1.webp" alt="" class="w-px-40 h-auto rounded-circle">
</div>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="#">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar avatar-online">
<img src="../assets/img/avatars/1.webp" alt="" class="w-px-40 h-auto rounded-circle">
</div>
</div>
<div class="flex-grow-1">
<span class="fw-semibold d-block">@_username</span>
<small class="text-muted">User</small>
</div>
</div>
</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item" href="#">
<i class="bx bx-user me-2"></i>
<span class="align-middle">My Profile</span>
</a>
</li>
<li>
<a class="dropdown-item" href="#">
<i class="bx bx-cog me-2"></i>
<span class="align-middle">Settings</span>
</a>
</li>
<li>
<a class="dropdown-item" href="#">
<span class="d-flex align-items-center align-middle">
<i class="flex-shrink-0 bx bx-credit-card me-2"></i>
<span class="flex-grow-1 align-middle">Billing</span>
<span class="flex-shrink-0 badge badge-center rounded-pill bg-danger w-px-20 h-px-20">4</span>
</span>
</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
<li>
<a class="dropdown-item" href="/user/logout">
<i class="bx bx-power-off me-2"></i>
<span class="align-middle">Log Out</span>
</a>
</li>
</ul>
</li>
<!--/ User -->
</ul>
</div>
</nav>
@code {
private string _username = "";
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
Refresh();
}
private void Refresh()
{
if (UserService.UserLoaded)
{
_username = UserService.User().Email ?? "";
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,108 @@
using AliasVault.Admin.Models;
using AliasVault.Admin.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Microsoft.JSInterop;
namespace AliasVault.Admin.Components.Pages;
/// <summary>
/// Base authorize page that all pages that are part of the logged in website should inherit from.
/// All pages that inherit from this class will require the user to be logged in and have a confirmed email.
/// Also, a default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
public class AuthorizePageBase : OwningComponentBase
{
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
[Inject]
protected UserService UserService { get; set; } = null!;
[Inject]
protected PortalMessageService PortalMessageService { get; set; } = null!;
[Inject]
public JsInvokeService JsInvokeService { get; set; } = null!;
[Inject]
public IJSRuntime Js { get; set; } = null!;
/// <summary>
/// Contains the breadcrumb items for the page. A default set of breadcrumbs is added in the parent OnInitialized method.
/// </summary>
protected List<BreadcrumbItem> BreadcrumbItems { get; set; } = new List<BreadcrumbItem>();
private bool _parametersInitialSet;
protected override async Task OnInitializedAsync()
{
if (!await AccessCheck()) return;
await base.OnInitializedAsync();
_parametersInitialSet = false;
// Add base breadcrumbs
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Home", Url = NavigationManager.BaseUri });
// Detect success messages in query string and add them to the SuccessMessages list
var uri = new Uri(NavigationManager.Uri);
PortalMessageService.RetrieveMessagesFromQueryString(uri);
// Call the GenericAccessCheckAsync method and halt execution if a redirect is required.
var verifyAccessCheckTask = await AccessCheckService.GenericAccessCheckAsync(NavigationManager, UserService);
if (verifyAccessCheckTask != true)
{
// Keep the page from loading if the user is not authorized by calling an infinite loop.
// We wait for navigation to happen during the infinite loop.
while (true)
{
await Task.Delay(1000);
}
}
}
/// <summary>
/// Issue a redirect to the login page if the user is not logged in.
/// </summary>
/// <returns></returns>
protected async Task<bool> AccessCheck()
{
// Call the GenericAccessCheckAsync method and halt execution if a redirect is required.
var verifyAccessCheckTask = await AccessCheckService.GenericAccessCheckAsync(NavigationManager, UserService);
if (verifyAccessCheckTask != true)
{
// Keep the page from loading if the user is not authorized by calling an infinite loop.
// We wait for navigation to happen during the infinite loop.
while (true)
{
await Task.Delay(1000);
return false;
}
}
return true;
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
// This is needed to prevent the OnParametersSetAsync method from running together with OnInitialized on initial page load.
if (!_parametersInitialSet)
{
_parametersInitialSet = true;
return;
}
// Call the GenericAccessCheckAsync method and halt execution if a redirect is required.
var verifyAccessCheckTask = await AccessCheckService.GenericAccessCheckAsync(NavigationManager, UserService);
if (verifyAccessCheckTask != true)
{
// Keep the page from loading if the user is not authorized by calling an infinite loop.
// We wait for navigation to happen during the infinite loop.
while (true)
{
await Task.Delay(1000);
}
}
}
}

View File

@@ -0,0 +1,37 @@
@inherits AuthorizePageBase
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,65 @@
@using AliasServerDb
@using Microsoft.EntityFrameworkCore
@inherits AuthorizePageBase
@inject AliasServerDbContext dbContext
@page "/"
<PageTitle>Emails</PageTitle>
<div class="row align-items-center">
<div class="col">
<h1>Emails</h1>
</div>
<div class="col-auto">
<a href="/add-alias" class="btn btn-success">
<span class="bi bi-file-earmark-plus" aria-hidden="true"></span> + Add new alias
</a>
</div>
</div>
<p>Find all received emails below.</p>
@if (IsLoading)
{
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
@foreach (var email in Emails)
{
<div>
<div>@email.From</div>
<div>@email.MessagePreview</div>
</div>
}
</div>
}
@code {
private bool IsLoading { get; set; } = true;
private List<Email> Emails { get; set; } = new List<Email>();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadAliasesAsync();
}
}
private async Task LoadAliasesAsync()
{
IsLoading = true;
StateHasChanged();
// Load the aliases from the database.
var user = UserService.User();
Emails = await dbContext.Emails
.ToListAsync();
IsLoading = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -0,0 +1,20 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
{
return;
}
<div class="alert alert-danger d-flex align-items-center alert-dismissible fade show" role="alert">
<span class="material-symbols-rounded align-middle me-2">error</span>
<div>
@Message
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,21 @@
@using Microsoft.IdentityModel.Tokens
@inherits ComponentBase
@if (Message.IsNullOrEmpty())
{
return;
}
<div class="alert alert-success d-flex align-items-center alert-dismissible fade show" role="alert">
<span class="material-symbols-rounded align-middle me-2">check_circle</span>
<div>
@Message
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@code {
[Parameter]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,23 @@
@using AliasVault.Admin.Models
@inherits ComponentBase
@if (BreadcrumbItems.Any())
{
<h4 class="fw-bold py-3 mb-4">
<span class="text-muted fw-light">
@foreach (var item in BreadcrumbItems)
{
@if (item != BreadcrumbItems.Last())
{
<span><a href="@item.Url">@item.DisplayName</a></span><text> / </text>
}
}
</span>
@BreadcrumbItems.Last().DisplayName
</h4>
}
@code {
[Parameter]
public List<BreadcrumbItem> BreadcrumbItems { get; set; } = new List<BreadcrumbItem>();
}

View File

@@ -0,0 +1,31 @@
@inject IJSRuntime JSRuntime
<div class="mb-3 row">
<label for="inputEmail3" class="col-sm-2 col-form-label">@Label</label>
<div class="col-sm-10">
<input type="text" readonly="readonly" aria-readonly="true" class="form-control @(_copied ? "is-valid" : "")" value="@Value" onclick="@CopyToClipboard">
</div>
</div>
@code {
[Parameter]
public string Label { get; set; }
[Parameter]
public string Value { get; set; }
private bool _copied = false;
public async Task CopyToClipboard()
{
await JSRuntime.InvokeVoidAsync("window.clipboardCopy.copyText", Value);
_copied = true;
StateHasChanged();
// After 2 seconds, remove the success class again
await Task.Delay(2000);
_copied = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,24 @@
@if (faviconBytes != null)
{
<img src="@faviconDataUrl" style="width: 50px;" alt="Favicon" />
}
else
{
<img src="img/service-placeholder.webp" style="width: 50px;" alt="Favicon" />
}
@code {
[Parameter]
public byte[]? faviconBytes { get; set; }
private string? faviconDataUrl;
protected override void OnParametersSet()
{
if (faviconBytes != null)
{
string base64String = Convert.ToBase64String(faviconBytes);
faviconDataUrl = $"data:image/x-icon;base64,{base64String}";
}
}
}

View File

@@ -0,0 +1,7 @@
 <div class="col-12">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
@using AliasVault.Pages
@using Microsoft.Extensions.Configuration
<PageTitle>@PageTitlePrefix AliasVault - @PageTitleSuffix</PageTitle>
@code {
[Parameter]
public string PageTitleSuffix { get; set; }
public string PageTitlePrefix { get; set; } = "";
protected override void OnInitialized()
{
}
}

View File

@@ -0,0 +1,12 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AliasVault.Admin
@using AliasVault.Admin.Components
@using AliasVault.Admin.Components.Shared

View File

@@ -0,0 +1,33 @@
# Use the official .NET 8 SDK image to build the app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy the solution file to the /src directory in the container
COPY aliasvault.sln ./
# Copy the project file to the /src/AliasVault directory in the container
COPY src/AliasVault/AliasVault.csproj ./AliasVault/
# Restore dependencies for the AliasVault project
RUN dotnet restore "./AliasVault/AliasVault.csproj" --verbosity detailed
# Copy the rest of the application code to the /src directory in the container
COPY src/. ./
# Publish the application to the /app/publish directory in the container
WORKDIR /src/AliasVault
RUN dotnet publish -c Release -o /app --verbosity detailed
# Use the official ASP.NET Core runtime image to run the app
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app/AliasVault
# Copy the published output from the build stage to the runtime stage
COPY --from=build /app ./
# Expose the port the app runs on
EXPOSE 8082
ENV ASPNETCORE_URLS=http://+:8082
ENTRYPOINT ["dotnet", "AliasVault.dll"]

View File

@@ -0,0 +1,75 @@
using System.Security.Claims;
using AliasServerDb;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
namespace AliasVault.Admin.Identity;
public class ClaimsTransformer : IClaimsTransformation
{
private readonly IDbContextFactory<AliasServerDbContext> _dbContextFactory;
public ClaimsTransformer(IDbContextFactory<AliasServerDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// Check if the user is authenticated
if (!principal.Identity?.IsAuthenticated ?? false)
{
return principal;
}
using (var dbContext = await _dbContextFactory.CreateDbContextAsync())
{
// Get the user
var user = await dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == principal.Identity.Name);
if (user == null)
{
return principal;
}
// Get the user's roles and claims in a single database call
var userRolesAndClaims = await (
from userRole in dbContext.AdminUserRoles
join role in dbContext.AdminRoles on userRole.RoleId equals role.Id
where userRole.UserId == user.Id
select new { userRole, role }
).ToListAsync();
var userClaims = await dbContext.AdminUserClaims.Where(x => x.UserId == user.Id).ToListAsync();
// Convert roles to claims
var roleClaims = userRolesAndClaims
.Select(uc => new Claim(ClaimTypes.Role, uc.role.Name))
.ToList();
// Add the user's claims to the role claims
foreach (var userClaim in userClaims)
{
roleClaims.Add(new Claim(userClaim.ClaimType, userClaim.ClaimValue));
}
// Filter out the role claims from the original claims, and use that to append to the new identity
HashSet<string> roleTypesToRemove = new HashSet<string>
{
ClaimTypes.Role,
//EventPermissionHandler.EventPermissionClaimType,
};
var nonRoleClaims = principal.Claims.Where(c => !roleTypesToRemove.Contains(c.Type));
// Create new identity and return it
var identity = new ClaimsIdentity(
nonRoleClaims.Concat(roleClaims),
principal.Identity.AuthenticationType,
ClaimTypes.Name,
ClaimTypes.Role
);
return new ClaimsPrincipal(identity);
}
}
}

View File

@@ -0,0 +1,68 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace AliasVault.Admin.Identity;
public class RevalidatingIdentityAuthenticationStateProvider<TUser>
: RevalidatingServerAuthenticationStateProvider where TUser : class
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IdentityOptions _options;
public RevalidatingIdentityAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> optionsAccessor)
: base(loggerFactory)
{
_scopeFactory = scopeFactory;
_options = optionsAccessor.Value;
}
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
var scope = _scopeFactory.CreateScope();
try
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
finally
{
if (scope is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
scope.Dispose();
}
}
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user == null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace AliasVault.Admin.Models;
public class BreadcrumbItem
{
public string? DisplayName { get; set; }
public string? Url { get; set; }
}

View File

@@ -0,0 +1,154 @@
@page "/"
@using AliasVault.Admin.Components
@using AliasVault.Admin.Services
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Mvc.TagHelpers
@namespace AliasVault.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject VersionedContentService VersionService
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="~/"/>
<link rel="stylesheet" href="@VersionService.GetVersionedPath("bootstrap/bootstrap.min.css")" />
<link href="@VersionService.GetVersionedPath("app.css")" rel="stylesheet" />
<link href="AliasVault.styles.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png"/>
<!--Google web fonts-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital@0;1&family=Open+Sans:wght@300..800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0" />
<!-- Core CSS -->
<link rel="stylesheet" href="/assets/vendor/css/core.css" class="template-customizer-core-css" />
<link rel="stylesheet" href="/assets/vendor/css/theme-default.css" class="template-customizer-theme-css" />
<link rel="stylesheet" href="/assets/css/demo.css" />
<!-- Vendors CSS -->
<link rel="stylesheet" href="/assets/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" />
<!-- Page CSS -->
<!-- Helpers -->
<script src="/assets/vendor/js/helpers.js"></script>
<!--! Template customizer & Theme config files MUST be included after core stylesheets and helpers.js in the <head> section -->
<!--? Config: Mandatory theme config file contain global vars & default theme options, Set your preferred theme option in this file. -->
<script src="/assets/js/config.js"></script>
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered"/>
</head>
<body>
<component type="typeof(App)" render-mode="Server"/>
<div id="components-reconnect-modal" class="aliasVault-reconnect-modal components-reconnect-hide">
<!-- Show class content -->
<div class="show">
<div class="position-fixed top-0 end-0 bottom-0 start-0 d-flex align-items-center justify-content-center" style="background-color: rgba(0, 0, 0, 0.5);">
<div class="card text-center p-5 shadow" style="max-width: 32rem;">
<p class="text-muted mb-4">
The connection to AliasVault has been lost. Reload the page to continue.
</p>
<button onclick="location.reload()" class="btn btn-primary">
Reload the page
</button>
</div>
</div>
</div>
<!-- Failed class content -->
<div class="failed">
<div class="position-fixed top-0 end-0 bottom-0 start-0 d-flex align-items-center justify-content-center" style="background-color: rgba(0, 0, 0, 0.5);">
<div class="card text-center p-5 shadow" style="max-width: 32rem;">
<p class="text-muted mb-4">
The connection to AliasVault has been lost. Reload the page to continue.
</p>
<button onclick="location.reload()" class="btn btn-primary">
Reload the page
</button>
</div>
</div>
</div>
<!-- Rejected class content -->
<div class="rejected">
<div class="position-fixed top-0 end-0 bottom-0 start-0 d-flex align-items-center justify-content-center" style="background-color: rgba(0, 0, 0, 0.5);">
<div class="card text-center p-5 shadow" style="max-width: 32rem;">
<p class="text-muted mb-4">
The connection to AliasVault has been lost. Reload the page to continue.
</p>
<button onclick="location.reload()" class="btn btn-primary">
Reload the page
</button>
</div>
</div>
</div>
</div>
<div id="blazor-error-ui">
<environment include="Staging,Production">
An error has occurred. This application may no longer respond until reloaded.
</environment>
<environment include="Development">
An unhandled exception has occurred. See browser dev tools for details.
</environment>
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script>
window.clipboardCopy = {
copyText: function (text) {
navigator.clipboard.writeText(text).then(function () { })
.catch(function (error) {
alert(error);
});
}
};
window.getFragment = () => {
return window.location.hash;
};
window.navigateToUrl = (url) => {
try {
window.location.href = url;
return true;
} catch (error) {
console.error(error);
return false;
}
};
window.isFunctionDefined = function(functionName) {
return typeof window[functionName] === 'function';
};
</script>
<!-- Core JS -->
<!-- build:js assets/vendor/js/core.js -->
<script src="/assets/vendor/libs/jquery/jquery.js"></script>
<script src="/assets/vendor/libs/popper/popper.js"></script>
<script src="/assets/vendor/js/bootstrap.js"></script>
<script src="/assets/vendor/libs/perfect-scrollbar/perfect-scrollbar.js"></script>
<script src="/assets/vendor/js/menu.js"></script>
<!-- endbuild -->
<!-- Vendors JS -->
<!-- Main JS -->
<script src="/assets/js/main.js"></script>
<!-- Page JS -->
<script src="_framework/blazor.server.js"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
using System.Data.Common;
using AliasServerDb;
using AliasVault.Admin;
using AliasVault.Admin.Identity;
using AliasVault.Admin.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// We use dbContextFactory to create a new instance of the DbContext for every place that needs it
// as otherwise concurrency issues may occur if we use a single instance of the DbContext across the application.
builder.Services.AddSingleton<DbConnection>(container =>
{
var configFile = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var connection = new SqliteConnection(configFile.GetConnectionString("AliasServerDbContext"));
connection.Open();
return connection;
});
builder.Services.AddDbContextFactory<AliasServerDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection).UseLazyLoadingProxies();
});
builder.Services.AddDataProtection();
builder.Services.Configure<DataProtectionTokenProviderOptions>(options =>
options.TokenLifespan = TimeSpan.FromHours(12));
builder.Services.AddIdentity<AdminUser, AdminRole>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 8;
options.Password.RequiredUniqueChars = 0;
options.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<AliasServerDbContext>()
.AddDefaultTokenProviders();
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
builder.Services.AddScoped<JsInvokeService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<PortalMessageService>();
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<AdminUser>>();
builder.Services.AddTransient<IClaimsTransformation, ClaimsTransformer>();
builder.Services.AddSingleton(new VersionedContentService(Directory.GetCurrentDirectory() + "/wwwroot"));
// Force all app generated URLs to be lowercase as this improves SEO.
builder.Services.AddRouting(options => options.LowercaseUrls = true);
// Add services to the container.
if (!builder.Environment.IsDevelopment())
{
// Normal production use
builder.Services.AddServerSideBlazor();
}
else
{
// Dev use
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(e => {
e.DetailedErrors = true;
});
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddRazorPages();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
using (var scope = app.Services.CreateScope())
{
await StartupTasks.CreateRolesIfNotExist(scope.ServiceProvider);
}
app.Run();

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:32869",
"sslPort": 44372
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5280",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7004;http://localhost:5280",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace AliasVault.Admin.Services;
public class AccessCheckService
{
public static async Task<bool> GenericAccessCheckAsync(NavigationManager navigationManager, UserService userService)
{
await userService.LoadCurrentUserAsync();
if (userService.UserLoaded)
{
// TODO: re-enable email confirmation check later.
/*if (!userService.User().EmailConfirmed)
{
// Redirect to email confirmation page if we are not already there
if (navigationManager.ToBaseRelativePath(navigationManager.Uri) != "account/confirm-email")
{
navigationManager.NavigateTo($"account/confirm-email");
return false; // Halt further execution.
}
} */
// User is logged in and email is confirmed.
return true;
}
else
{
string returnUrl = navigationManager.ToBaseRelativePath(navigationManager.Uri);
string loginUrl;
if (string.IsNullOrEmpty(returnUrl) || returnUrl == "/" || returnUrl == "user/login")
{
loginUrl = "user/login";
}
else
{
loginUrl = $"user/login?returnUrl=/{Uri.EscapeDataString(returnUrl)}";
}
navigationManager.NavigateTo(loginUrl, true);
return false;
}
}
}

View File

@@ -0,0 +1,49 @@
namespace AliasVault.Admin.Services;
using Microsoft.JSInterop;
/// <summary>
/// Service for invoking JavaScript functions from C#.
/// </summary>
public class JsInvokeService
{
private IJSRuntime Js { get; }
/// <summary>
/// Initializes a new instance of the <see cref="JsInvokeService"/> class.
/// </summary>
/// <param name="js">The IJSRuntime object.</param>
public JsInvokeService(IJSRuntime js)
{
Js = js;
}
public async Task RetryInvokeAsync(string functionName, TimeSpan initialDelay, int maxAttempts, params object[] args)
{
TimeSpan delay = initialDelay;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
bool isDefined = await this.Js.InvokeAsync<bool>("isFunctionDefined", functionName);
if (isDefined)
{
await this.Js.InvokeVoidAsync(functionName, args);
return; // Successfully called the JS function, exit the method
}
}
catch (Exception ex)
{
// Optionally log the exception
}
// Wait for the delay before the next attempt
await Task.Delay(delay);
// Exponential backoff: double the delay for the next attempt
delay = TimeSpan.FromTicks(delay.Ticks * 2);
}
// Optionally log that the JS function could not be called after maxAttempts
}
}

View File

@@ -0,0 +1,79 @@
using System.Web;
namespace AliasVault.Admin.Services;
/// <summary>
/// Handles portal messages that should be displayed to the user, such as success or error messages. These messages
/// are stored in this object which is scoped to the current session. This allows the messages to be cached until
/// they actually have been displayed. So they can survive redirects and page reloads.
/// </summary>
public class PortalMessageService
{
/// <summary>
/// Contains success messages that should be displayed to the user. A default set of success messages is added in the parent OnInitialized method.
/// </summary>
protected List<string> SuccessMessages { get; set; } = new List<string>();
/// <summary>
/// Allow other components to subscribe to changes in the event object.
/// </summary>
public event Action? OnChange;
private void NotifyStateChanged() => OnChange?.Invoke();
/// <summary>
/// Public constructor which can be called from static async method or directly.
/// </summary>
public PortalMessageService()
{
}
public void AddSuccessMessage(string message, bool notifyStateChanged = true)
{
SuccessMessages.Add(message);
// Notify subscribers that a message has been added.
if (notifyStateChanged)
{
NotifyStateChanged();
}
}
/// <summary>
/// Returns a dictionary with messages that should be displayed to the user. After this method is called,
/// the messages are automatically cleared.
/// </summary>
/// <returns></returns>
public Dictionary<string, string> GetMessagesForDisplay()
{
var messages = new Dictionary<string, string>();
foreach (var message in SuccessMessages)
{
messages.Add("success", message);
}
// Clear messages
SuccessMessages.Clear();
return messages;
}
/// <summary>
/// Retrieves messages from the query string (if any) and adds them to the correct messages list.
/// </summary>
/// <param name="uri"></param>
public void RetrieveMessagesFromQueryString(Uri uri)
{
var query = HttpUtility.ParseQueryString(uri.Query);
var successMessage = query.Get("successMessage");
if (!string.IsNullOrEmpty(successMessage))
{
switch (successMessage)
{
case "EmailConfirmed":
AddSuccessMessage("Your email has successfully been confirmed!");
break;
}
}
}
}

View File

@@ -0,0 +1,388 @@
namespace AliasVault.Admin.Services;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// User service for managing users.
/// </summary>
public class UserService
{
private readonly AliasServerDbContext _dbContext;
private readonly UserManager<AdminUser> _userManager;
private readonly SignInManager<AdminUser> _signInManager;
private AdminUser? _user;
private readonly IHttpContextAccessor _httpContextAccessor;
private const string AdminRole = "Admin";
/// <summary>
/// The Event Ids that the current user is allowed to manage.
/// </summary>
private List<Guid> _managedEventIds = new();
/// <summary>
/// The roles of the current user
/// </summary>
private IList<string> _userRoles = new List<string>();
/// <summary>
/// Whether the current user is an admin or not.
/// </summary>
private bool _isAdmin;
/// <summary>
/// Returns true if event is loaded and available, false if not. Use this before accessing Event() method.
/// </summary>
public bool UserLoaded => _user != null;
/// <summary>
/// Allow other components to subscribe to changes in the event object.
/// </summary>
public event Action OnChange = delegate { };
private void NotifyStateChanged() => OnChange.Invoke();
public UserService(AliasServerDbContext dbContext, UserManager<AdminUser> userManager, SignInManager<AdminUser> signInManager, IHttpContextAccessor httpContextAccessor)
{
_dbContext = dbContext;
_userManager = userManager;
_signInManager = signInManager;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// Returns all users.
/// </summary>
/// <returns></returns>
public async Task<List<AdminUser>> GetAllUsersAsync()
{
var userList = await _userManager.Users.ToListAsync();
return userList;
}
/// <summary>
/// Finds and returns user by id, using the _userManager instead of the _dbContext.
/// This is necessary when performing actions on the user, such as changing password or deleting the object.
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<AdminUser> GetUserByIdUserManagerAsync(Guid userId)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null)
{
throw new Exception($"User with id {userId} not found.");
}
return user;
}
/// <summary>
/// Returns inner event EF object.
/// </summary>
/// <returns></returns>
public AdminUser User()
{
if (_user == null)
{
throw new Exception("Trying to access User object which is null.");
}
return _user;
}
/// <summary>
/// Returns managed Event ids list.
/// </summary>
/// <returns></returns>
public List<Guid> UserAllowedEventIds()
{
return _managedEventIds;
}
/// <summary>
/// Returns whether current user is admin or not.
/// </summary>
/// <returns></returns>
public bool CurrentUserIsAdmin()
{
return _isAdmin;
}
/// <summary>
/// Returns current logged on user based on HttpContext.
/// </summary>
/// <returns></returns>
public async Task LoadCurrentUserAsync()
{
if (_httpContextAccessor.HttpContext != null)
{
// Load user from database. Use a new context everytime to ensure we get the latest data.
var user = await _dbContext.AdminUsers.FirstOrDefaultAsync(u => u.UserName == _httpContextAccessor.HttpContext.User.Identity.Name);
if (user != null)
{
_user = user;
// Load managed event ids for current user.
//_managedEventIds = await GetUserAllowedEventIdsAsync(_user);
// Load all roles for current user.
_userRoles = await _userManager.GetRolesAsync(this.User());
// Define if current user is admin.
_isAdmin = _userRoles.Contains(AdminRole);
}
// UserManager implementation: throughout Blazor server session user is not updated when user is updated in database
// because of UserManager EF cache. That's why we load it ourselves straight from the database via new DbContext
// to ensure we get the latest data everytime.
/*var currentUser = await _userManager.GetUserAsync(_httpContextAccessor.HttpContext.User);
if (currentUser != null)
{
_user = currentUser;
// Load managed event ids for current user.
_managedEventIds = await GetUserAllowedEventIdsAsync(_user);
// Load all roles for current user.
_userRoles = await _userManager.GetRolesAsync(User());
// Define if current user is admin.
_isAdmin = _userRoles.Contains(AdminRole);
}*/
}
// Notify listeners that the user has been loaded.
NotifyStateChanged();
}
/// <summary>
/// Returns current logged on user based on HttpContext.
/// </summary>
/// <returns></returns>
public async Task<string> GenerateEmailConfirmTokenAsync()
{
return await _userManager.GenerateEmailConfirmationTokenAsync(User());
}
/// <summary>
/// Returns current logged on user based on HttpContext.
/// </summary>
/// <returns></returns>
public async Task<IList<Claim>> GetCurrentUserClaimsAsync()
{
var claims = await _userManager.GetClaimsAsync(User());
return claims;
}
/// <summary>
/// Returns current logged on user based on HttpContext.
/// </summary>
/// <returns></returns>
public async Task<List<string>> GetCurrentUserRolesAsync()
{
var roles = await _userManager.GetRolesAsync(User());
return roles.ToList();
}
public async Task<List<AdminUser>> SearchUsersAsync(string searchTerm)
{
return await _userManager.Users.Where(x => x.UserName.Contains(searchTerm)).Take(5).ToListAsync();
}
/// <summary>
/// Sign out the current user.
/// </summary>
public async Task SignOutAsync()
{
await _signInManager.SignOutAsync();
}
public async Task<List<string>> CreateUserAsync(AdminUser user, string password, List<string> roles)
{
var errors = await ValidateUser(user, password, isUpdate: false);
if (errors.Count > 0)
{
return errors;
}
var result = await _userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
errors.Add(error.Description);
}
return errors;
}
errors = await UpdateUserRolesAsync(user, roles);
return errors;
}
public async Task<List<string>> UpdateUserAsync(AdminUser user, string newPassword)
{
var errors = await ValidateUser(user, newPassword, isUpdate: true);
if (errors.Count > 0)
{
return errors;
}
// Update password if necessary
if (!string.IsNullOrEmpty(newPassword))
{
var passwordRemoveResult = await this._userManager.RemovePasswordAsync(user);
if (!passwordRemoveResult.Succeeded)
{
foreach (var error in passwordRemoveResult.Errors)
{
errors.Add(error.Description);
}
return errors;
}
var passwordAddResult = await this._userManager.AddPasswordAsync(user, newPassword);
if (!passwordAddResult.Succeeded)
{
foreach (var error in passwordAddResult.Errors)
{
errors.Add(error.Description);
}
return errors;
}
}
var result = await this._userManager.UpdateAsync(user);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
errors.Add(error.Description);
}
return errors;
}
return errors;
}
/// <summary>
/// Update user roles. This is a separate method because it is called from both CreateUserAsync and UpdateUserAsync.
/// </summary>
/// <param name="user"></param>
/// <param name="roles"></param>
/// <returns></returns>
public async Task<List<string>> UpdateUserRolesAsync(AdminUser user, List<string> roles)
{
List<string> errors = new();
var currentRoles = await _userManager.GetRolesAsync(user);
if (user.Id == User().Id && currentRoles.Contains(AdminRole) && !roles.Contains(AdminRole))
{
errors.Add("You cannot remove the Admin role from yourself if you are an Admin.");
return errors;
}
var rolesToAdd = roles.Except(currentRoles).ToList();
var rolesToRemove = currentRoles.Except(roles).ToList();
await this._userManager.AddToRolesAsync(user, rolesToAdd);
await this._userManager.RemoveFromRolesAsync(user, rolesToRemove);
return errors;
}
private async Task<List<string>> ValidateUser(AdminUser user, string password, bool isUpdate)
{
// Username and email are the same, so enforce any changes to username here to email as well
user.Email = user.UserName;
var errors = new List<string>();
if (string.IsNullOrEmpty(user.UserName) || string.IsNullOrEmpty(user.Email))
{
errors.Add("Username and email are required.");
return errors;
}
if (!new EmailAddressAttribute().IsValid(user.Email))
{
errors.Add("Email is not valid.");
return errors;
}
if (isUpdate)
{
var originalUser = await this._userManager.FindByIdAsync(user.Id);
if (user.UserName != originalUser.UserName)
{
errors.Add("Username cannot be changed for existing users.");
}
}
else
{
var existingUser = await this._userManager.FindByNameAsync(user.UserName);
if (existingUser != null)
{
errors.Add("Username is already in use.");
}
var existingEmail = await this._userManager.FindByEmailAsync(user.Email);
if (existingEmail != null)
{
errors.Add("Email is already in use.");
}
if (string.IsNullOrEmpty(password))
{
errors.Add("Password is required.");
}
}
return errors;
}
public async Task<List<string>> DeleteUserAsync(AdminUser user, bool forceDelete = false)
{
var errors = new List<string>();
// Disallow deleting yourself, except when forceDelete is true
if (user.Id == User().Id && !forceDelete)
{
errors.Add("You cannot delete yourself.");
return errors;
}
// First delete all related data...
// @TODO: do we not want to preserve certain anonymized data?
// ...then delete the user
var result = await _userManager.DeleteAsync(user);
if (!result.Succeeded)
{
// Handle error, e.g. show error messages
errors.Add("Unable to delete user.");
}
return errors;
}
/// <summary>
/// Checks if supplied password is correct for the user.
/// </summary>
/// <returns></returns>
public async Task<bool> CheckPasswordAsync(AdminUser user, string password)
{
if (password.Length == 0)
{
return false;
}
return await _userManager.CheckPasswordAsync(user, password);
}
}

View File

@@ -0,0 +1,38 @@
using System.Security.Cryptography;
namespace AliasVault.Admin.Services;
/// <summary>
/// Service to provide versioned content paths for cache busting of static files.
/// </summary>
public class VersionedContentService
{
private readonly string _webRootPath;
public VersionedContentService(string webRootPath)
{
_webRootPath = webRootPath ?? throw new ArgumentNullException(nameof(webRootPath));
}
private Dictionary<string, string> _hashCache = new Dictionary<string, string>();
public string GetVersionedPath(string contentPath)
{
if (!_hashCache.TryGetValue(contentPath, out var version))
{
var serverPath = Path.Combine(_webRootPath, contentPath.TrimStart('/'));
version = GetVersionHashFrom(serverPath);
_hashCache[contentPath] = version;
}
return $"{contentPath}?v={version}";
}
private string GetVersionHashFrom(string serverPath)
{
using var md5 = MD5.Create();
using var stream = File.OpenRead(serverPath);
byte[] hash = md5.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}

View File

@@ -0,0 +1,27 @@
namespace AliasVault.Admin;
using AliasServerDb;
using Microsoft.AspNetCore.Identity;
/// <summary>
/// Startup tasks that should be run when the application starts.
/// </summary>
public class StartupTasks
{
/// <summary>
/// Creates the roles if they do not exist.
/// </summary>
/// <param name="serviceProvider">IServiceProvider instance.</param>
/// <returns>Task.</returns>
public static async Task CreateRolesIfNotExist(IServiceProvider serviceProvider)
{
var roleManager = serviceProvider.GetRequiredService<RoleManager<AdminRole>>();
const string adminRole = "Admin";
if (!await roleManager.RoleExistsAsync(adminRole))
{
await roleManager.CreateAsync(new AdminRole(adminRole));
}
}
}

View File

@@ -0,0 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"AliasServerDbContext": "Data Source=../../database/AliasServerDb.sqlite"
}
}

View File

@@ -0,0 +1,178 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.nevocard-reconnect-modal > div {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1000;
overflow: hidden;
opacity: 1;
text-align: center;
font-weight: bold;
}
.components-reconnect-hide > div {
display: none;
}
.components-reconnect-show > div {
display: none;
}
.components-reconnect-show > .show {
display: block;
}
.components-reconnect-failed > div {
display: none;
}
.components-reconnect-failed > .failed {
display: block;
}
.components-reconnect-rejected > div {
display: none;
}
.components-reconnect-rejected > .rejected {
display: block;
}
.gradient-custom {
/* fallback for old browsers */
background: rgb(203, 116, 17);
/* Chrome 10-25, Safari 5.1-6 */
background: -webkit-linear-gradient(
to right, rgb(203, 116, 17),
rgb(209, 200, 126)
);
/* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
background: linear-gradient(
to right, rgb(203, 116, 17),
rgb(209, 200, 126)
)
}

View File

@@ -0,0 +1,107 @@
/*
* demo.css
* File include item demo only specific css only
******************************************************************************/
.menu .app-brand.demo {
height: 64px;
margin-top: 12px;
}
.app-brand-logo.demo svg {
width: 22px;
height: 38px;
}
.app-brand-text.demo {
font-size: 1.75rem;
letter-spacing: -0.5px;
text-transform: lowercase;
}
/* ! For .layout-navbar-fixed added fix padding top tpo .layout-page */
/* Detached navbar */
.layout-navbar-fixed .layout-wrapper:not(.layout-horizontal):not(.layout-without-menu) .layout-page {
padding-top: 76px !important;
}
/* Default navbar */
.layout-navbar-fixed .layout-wrapper:not(.layout-without-menu) .layout-page {
padding-top: 64px !important;
}
/* Navbar page z-index issue solution */
.content-wrapper .navbar {
z-index: auto;
}
/*
* Content
******************************************************************************/
.demo-blocks > * {
display: block !important;
}
.demo-inline-spacing > * {
margin: 1rem 0.375rem 0 0 !important;
}
/* ? .demo-vertical-spacing class is used to have vertical margins between elements. To remove margin-top from the first-child, use .demo-only-element class with .demo-vertical-spacing class. For example, we have used this class in forms-input-groups.html file. */
.demo-vertical-spacing > * {
margin-top: 1rem !important;
margin-bottom: 0 !important;
}
.demo-vertical-spacing.demo-only-element > :first-child {
margin-top: 0 !important;
}
.demo-vertical-spacing-lg > * {
margin-top: 1.875rem !important;
margin-bottom: 0 !important;
}
.demo-vertical-spacing-lg.demo-only-element > :first-child {
margin-top: 0 !important;
}
.demo-vertical-spacing-xl > * {
margin-top: 5rem !important;
margin-bottom: 0 !important;
}
.demo-vertical-spacing-xl.demo-only-element > :first-child {
margin-top: 0 !important;
}
.rtl-only {
display: none !important;
text-align: left !important;
direction: ltr !important;
}
[dir='rtl'] .rtl-only {
display: block !important;
}
/*
* Layout demo
******************************************************************************/
.layout-demo-wrapper {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
margin-top: 1rem;
}
.layout-demo-placeholder img {
width: 900px;
}
.layout-demo-info {
text-align: center;
margin-top: 1rem;
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,27 @@
/**
* Config
* -------------------------------------------------------------------------------------
* ! IMPORTANT: Make sure you clear the browser local storage In order to see the config changes in the template.
* ! To clear local storage: (https://www.leadshook.com/help/how-to-clear-local-storage-in-google-chrome-browser/).
*/
'use strict';
// JS global variables
let config = {
colors: {
primary: '#696cff',
secondary: '#8592a3',
success: '#71dd37',
info: '#03c3ec',
warning: '#ffab00',
danger: '#ff3e1d',
dark: '#233446',
black: '#000',
white: '#fff',
body: '#f4f5fb',
headingColor: '#566a7f',
axisColor: '#a1acb8',
borderColor: '#eceef1'
}
};

Some files were not shown because too many files have changed in this diff Show More