Implemented basic user auth, register, login, details and avatar stuff from helio
This commit is contained in:
parent
3bb4e7daab
commit
49c893f515
41 changed files with 2079 additions and 212 deletions
15
.idea/.idea.Moonlight/.idea/efCoreCommonOptions.xml
Normal file
15
.idea/.idea.Moonlight/.idea/efCoreCommonOptions.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EfCoreCommonOptions">
|
||||
<option name="migrationsToStartupProjects">
|
||||
<map>
|
||||
<entry key="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" value="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="startupToMigrationsProjects">
|
||||
<map>
|
||||
<entry key="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" value="691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
13
.idea/.idea.Moonlight/.idea/efCoreDialogsState.xml
Normal file
13
.idea/.idea.Moonlight/.idea/efCoreDialogsState.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EfCoreDialogsState">
|
||||
<option name="keyValueStorage">
|
||||
<map>
|
||||
<entry key="Common:691e5ec2-4b4f-4bd1-9cbc-7d3c6efc12da:dbContext" value="Moonlight.App.Database.DataContext" />
|
||||
<entry key="Common:buildConfiguration" value="Debug" />
|
||||
<entry key="Common:noBuild" value="false" />
|
||||
<entry key="Common:outputFolder" value="App/Database/Migrations" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
67
Moonlight/App/Database/Migrations/20231013200303_AddedUser.Designer.cs
generated
Normal file
67
Moonlight/App/Database/Migrations/20231013200303_AddedUser.Designer.cs
generated
Normal file
|
@ -0,0 +1,67 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Moonlight.App.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.App.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
[Migration("20231013200303_AddedUser")]
|
||||
partial class AddedUser
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
|
||||
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Flags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("TokenValidTimestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TotpKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.App.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedUser : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Username = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Password = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Avatar = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TotpKey = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Flags = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Permissions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
TokenValidTimestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Moonlight.App.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.App.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(DataContext))]
|
||||
partial class DataContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
|
||||
modelBuilder.Entity("Moonlight.App.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Flags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("TokenValidTimestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TotpKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
37
Moonlight/App/Http/Controllers/Api/BucketController.cs
Normal file
37
Moonlight/App/Http/Controllers/Api/BucketController.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.App.Helpers;
|
||||
using Moonlight.App.Services;
|
||||
|
||||
namespace Moonlight.App.Http.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/bucket")]
|
||||
public class BucketController : Controller
|
||||
{
|
||||
private readonly BucketService BucketService;
|
||||
|
||||
public BucketController(BucketService bucketService)
|
||||
{
|
||||
BucketService = bucketService;
|
||||
}
|
||||
|
||||
[HttpGet("{bucket}/{file}")]
|
||||
public async Task<ActionResult> Get([FromRoute] string bucket, [FromRoute] string file) // TODO: Implement auth
|
||||
{
|
||||
if (bucket.Contains("..") || file.Contains(".."))
|
||||
{
|
||||
Logger.Warn($"Detected path transversal attack ({Request.HttpContext.Connection.RemoteIpAddress}).", "security");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stream = await BucketService.Pull(bucket, file);
|
||||
return File(stream, MimeTypes.GetMimeType(file));
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
}
|
13
Moonlight/App/Models/Forms/LoginForm.cs
Normal file
13
Moonlight/App/Models/Forms/LoginForm.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class LoginForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to provide an email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to enter a valid email address")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a password")]
|
||||
public string Password { get; set; }
|
||||
}
|
26
Moonlight/App/Models/Forms/RegisterForm.cs
Normal file
26
Moonlight/App/Models/Forms/RegisterForm.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class RegisterForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to provide an username")]
|
||||
[MinLength(7, ErrorMessage = "The username is too short")]
|
||||
[MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
|
||||
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide an email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to enter a valid email address")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a password")]
|
||||
[MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
|
||||
public string Password { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a password")]
|
||||
[MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
|
||||
public string RepeatedPassword { get; set; }
|
||||
}
|
10
Moonlight/App/Models/Forms/ResetPasswordForm.cs
Normal file
10
Moonlight/App/Models/Forms/ResetPasswordForm.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class ResetPasswordForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to specify an email address")]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
9
Moonlight/App/Models/Forms/TwoFactorCodeForm.cs
Normal file
9
Moonlight/App/Models/Forms/TwoFactorCodeForm.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class TwoFactorCodeForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to enter a two factor code")]
|
||||
public string Code { get; set; } = "";
|
||||
}
|
16
Moonlight/App/Models/Forms/UpdateAccountForm.cs
Normal file
16
Moonlight/App/Models/Forms/UpdateAccountForm.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class UpdateAccountForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to provide an username")]
|
||||
[MinLength(7, ErrorMessage = "The username is too short")]
|
||||
[MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
|
||||
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide an email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to enter a valid email address")]
|
||||
public string Email { get; set; }
|
||||
}
|
16
Moonlight/App/Models/Forms/UpdateAccountPasswordForm.cs
Normal file
16
Moonlight/App/Models/Forms/UpdateAccountPasswordForm.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class UpdateAccountPasswordForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to specify a password")]
|
||||
[MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Required(ErrorMessage = "You need to repeat your new password")]
|
||||
[MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
|
||||
public string RepeatedPassword { get; set; } = "";
|
||||
}
|
16
Moonlight/App/Models/Forms/UpdateUserForm.cs
Normal file
16
Moonlight/App/Models/Forms/UpdateUserForm.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class UpdateUserForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to enter a username")]
|
||||
[MinLength(7, ErrorMessage = "The username is too short")]
|
||||
[MaxLength(20, ErrorMessage = "The username cannot be longer than 20 characters")]
|
||||
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Required(ErrorMessage = "You need to enter a email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to enter a valid email address")]
|
||||
public string Email { get; set; } = "";
|
||||
}
|
11
Moonlight/App/Models/Forms/UpdateUserPasswordForm.cs
Normal file
11
Moonlight/App/Models/Forms/UpdateUserPasswordForm.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
public class UpdateUserPasswordForm
|
||||
{
|
||||
[Required(ErrorMessage = "You need to specify a password")]
|
||||
[MinLength(8, ErrorMessage = "The password must be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "The password must not be longer than 256 characters")]
|
||||
public string Password { get; set; } = "";
|
||||
}
|
40
Moonlight/App/Repositories/Repository.cs
Normal file
40
Moonlight/App/Repositories/Repository.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.App.Database;
|
||||
|
||||
namespace Moonlight.App.Repositories;
|
||||
|
||||
public class Repository<TEntity> where TEntity : class
|
||||
{
|
||||
private readonly DataContext DataContext;
|
||||
private readonly DbSet<TEntity> DbSet;
|
||||
|
||||
public Repository(DataContext dbContext)
|
||||
{
|
||||
DataContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
DbSet = DataContext.Set<TEntity>();
|
||||
}
|
||||
|
||||
public DbSet<TEntity> Get()
|
||||
{
|
||||
return DbSet;
|
||||
}
|
||||
|
||||
public TEntity Add(TEntity entity)
|
||||
{
|
||||
var x = DbSet.Add(entity);
|
||||
DataContext.SaveChanges();
|
||||
return x.Entity;
|
||||
}
|
||||
|
||||
public void Update(TEntity entity)
|
||||
{
|
||||
DbSet.Update(entity);
|
||||
DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
public void Delete(TEntity entity)
|
||||
{
|
||||
DbSet.Remove(entity);
|
||||
DataContext.SaveChanges();
|
||||
}
|
||||
}
|
66
Moonlight/App/Services/BucketService.cs
Normal file
66
Moonlight/App/Services/BucketService.cs
Normal file
|
@ -0,0 +1,66 @@
|
|||
using Moonlight.App.Helpers;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
public class BucketService
|
||||
{
|
||||
private readonly string BasePath;
|
||||
public string[] Buckets => GetBuckets();
|
||||
|
||||
|
||||
public BucketService()
|
||||
{
|
||||
// This is used to create the buckets folder in the persistent storage of helio
|
||||
BasePath = PathBuilder.Dir("storage", "buckets");
|
||||
Directory.CreateDirectory(BasePath);
|
||||
}
|
||||
|
||||
public string[] GetBuckets()
|
||||
{
|
||||
return Directory
|
||||
.GetDirectories(BasePath)
|
||||
.Select(x =>
|
||||
x.Replace(BasePath, "").TrimEnd('/')
|
||||
)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public Task EnsureBucket(string name) // To ensure a specific bucket has been created, call this function
|
||||
{
|
||||
Directory.CreateDirectory(PathBuilder.Dir(BasePath, name));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<string> Store(string bucket, Stream dataStream, string fileName)
|
||||
{
|
||||
await EnsureBucket(bucket); // Ensure the bucket actually exists
|
||||
|
||||
// Create a safe to file name to store the file
|
||||
var extension = Path.GetExtension(fileName);
|
||||
var finalFileName = Path.GetRandomFileName() + extension;
|
||||
var finalFilePath = PathBuilder.File(BasePath, bucket, finalFileName);
|
||||
|
||||
// Copy the file from the remote stream to the bucket
|
||||
var fs = File.Create(finalFilePath);
|
||||
await dataStream.CopyToAsync(fs);
|
||||
await fs.FlushAsync();
|
||||
fs.Close();
|
||||
|
||||
// Return the generated file name to save it in the db or smth
|
||||
return finalFileName;
|
||||
}
|
||||
|
||||
public Task<Stream> Pull(string bucket, string file)
|
||||
{
|
||||
var filePath = PathBuilder.File(BasePath, bucket, file);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var stream = File.Open(filePath, FileMode.Open);
|
||||
|
||||
return Task.FromResult<Stream>(stream);
|
||||
}
|
||||
else
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
}
|
170
Moonlight/App/Services/IdentityService.cs
Normal file
170
Moonlight/App/Services/IdentityService.cs
Normal file
|
@ -0,0 +1,170 @@
|
|||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Helpers;
|
||||
using Moonlight.App.Models.Abstractions;
|
||||
using Moonlight.App.Models.Enums;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services.Utils;
|
||||
using OtpNet;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
// This service allows you to reauthenticate, login and force login
|
||||
// It does also contain the permission system accessor for the current user
|
||||
public class IdentityService
|
||||
{
|
||||
private readonly Repository<User> UserRepository;
|
||||
private readonly JwtService JwtService;
|
||||
|
||||
private string Token;
|
||||
|
||||
public User? CurrentUserNullable { get; private set; }
|
||||
public User CurrentUser => CurrentUserNullable!;
|
||||
public bool IsSignedIn => CurrentUserNullable != null;
|
||||
public FlagStorage Flags { get; private set; } = new("");
|
||||
public PermissionStorage Permissions { get; private set; } = new(-1);
|
||||
public EventHandler OnAuthenticationStateChanged { get; set; }
|
||||
|
||||
public IdentityService(Repository<User> userRepository,
|
||||
JwtService jwtService)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
JwtService = jwtService;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
|
||||
public async Task Authenticate() // Reauthenticate
|
||||
{
|
||||
// Save the last id (or -1 if not set) so we can track a change
|
||||
var lastUserId = CurrentUserNullable == null ? -1 : CurrentUserNullable.Id;
|
||||
|
||||
// Reset
|
||||
CurrentUserNullable = null;
|
||||
|
||||
await ValidateToken();
|
||||
|
||||
// Get current user id to compare against the last one
|
||||
var currentUserId = CurrentUserNullable == null ? -1 : CurrentUserNullable.Id;
|
||||
|
||||
if (lastUserId != currentUserId) // State changed, lets notify all event listeners
|
||||
OnAuthenticationStateChanged?.Invoke(this, null!);
|
||||
}
|
||||
|
||||
private async Task ValidateToken() // Read and validate token
|
||||
{
|
||||
if (string.IsNullOrEmpty(Token))
|
||||
return;
|
||||
|
||||
if (!await JwtService.Validate(Token))
|
||||
return;
|
||||
|
||||
var data = await JwtService.Decode(Token);
|
||||
|
||||
if (!data.ContainsKey("userId"))
|
||||
return;
|
||||
|
||||
var userId = int.Parse(data["userId"]);
|
||||
|
||||
var user = UserRepository
|
||||
.Get()
|
||||
.FirstOrDefault(x => x.Id == userId);
|
||||
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
if (!data.ContainsKey("issuedAt"))
|
||||
return;
|
||||
|
||||
var issuedAt = long.Parse(data["issuedAt"]);
|
||||
var issuedAtDateTime = DateTimeOffset.FromUnixTimeSeconds(issuedAt).DateTime;
|
||||
|
||||
// If the valid time is newer then when the token was issued, the token is not longer valid
|
||||
if (user.TokenValidTimestamp > issuedAtDateTime)
|
||||
return;
|
||||
|
||||
CurrentUserNullable = user;
|
||||
|
||||
if (CurrentUserNullable == null) // If the current user is null, stop loading additional data
|
||||
return;
|
||||
|
||||
Flags = new(CurrentUser.Flags);
|
||||
Permissions = new(CurrentUser.Permissions);
|
||||
}
|
||||
|
||||
public async Task<string> Login(string email, string password, string? code = null)
|
||||
{
|
||||
var user = UserRepository
|
||||
.Get()
|
||||
.FirstOrDefault(x => x.Email == email);
|
||||
|
||||
if (user == null)
|
||||
throw new DisplayException("A user with these credential combination was not found");
|
||||
|
||||
if (!HashHelper.Verify(password, user.Password))
|
||||
throw new DisplayException("A user with these credential combination was not found");
|
||||
|
||||
var flags = new FlagStorage(user.Flags); // Construct FlagStorage to check for 2fa
|
||||
|
||||
if (!flags[UserFlag.TotpEnabled]) // No 2fa found on this user so were done here
|
||||
return await GenerateToken(user);
|
||||
|
||||
// If we reach this point, 2fa is enabled so we need to continue validating
|
||||
|
||||
if (string.IsNullOrEmpty(code)) // This will show an additional 2fa login field
|
||||
throw new ArgumentNullException(nameof(code), "2FA code missing");
|
||||
|
||||
if (user.TotpKey == null) // Hopefully we will never fulfill this check ;)
|
||||
throw new DisplayException("2FA key is missing. Please contact the support to fix your account");
|
||||
|
||||
// Calculate server side code
|
||||
var totp = new Totp(Base32Encoding.ToBytes(user.TotpKey));
|
||||
var codeServerSide = totp.ComputeTotp();
|
||||
|
||||
if (codeServerSide == code)
|
||||
return await GenerateToken(user);
|
||||
|
||||
throw new DisplayException("Invalid 2fa code entered");
|
||||
}
|
||||
|
||||
public async Task<string> GenerateToken(User user)
|
||||
{
|
||||
var token = await JwtService.Create(data =>
|
||||
{
|
||||
data.Add("userId", user.Id.ToString());
|
||||
data.Add("issuedAt", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
|
||||
}, TimeSpan.FromDays(10));
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
public Task SaveFlags()
|
||||
{
|
||||
// Prevent saving flags for an empty user
|
||||
if (!IsSignedIn)
|
||||
return Task.CompletedTask;
|
||||
|
||||
// Save the new flag string
|
||||
CurrentUser.Flags = Flags.RawFlagString;
|
||||
UserRepository.Update(CurrentUser);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Helpers and overloads
|
||||
public async Task
|
||||
Authenticate(HttpRequest request) // Overload for api controllers to authenticate a user like the normal panel
|
||||
{
|
||||
if (request.Cookies.ContainsKey("token"))
|
||||
{
|
||||
var token = request.Cookies["token"];
|
||||
await Authenticate(token!);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Authenticate(string token) // Overload to set token and reauth
|
||||
{
|
||||
Token = token;
|
||||
await Authenticate();
|
||||
}
|
||||
}
|
61
Moonlight/App/Services/Interop/CookieService.cs
Normal file
61
Moonlight/App/Services/Interop/CookieService.cs
Normal file
|
@ -0,0 +1,61 @@
|
|||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Moonlight.App.Services.Interop;
|
||||
|
||||
public class CookieService
|
||||
{
|
||||
private readonly IJSRuntime JsRuntime;
|
||||
private string Expires = "";
|
||||
|
||||
public CookieService(IJSRuntime jsRuntime)
|
||||
{
|
||||
JsRuntime = jsRuntime;
|
||||
ExpireDays = 300;
|
||||
}
|
||||
|
||||
public async Task SetValue(string key, string value, int? days = null)
|
||||
{
|
||||
var curExp = (days != null) ? (days > 0 ? DateToUTC(days.Value) : "") : Expires;
|
||||
await SetCookie($"{key}={value}; expires={curExp}; path=/");
|
||||
}
|
||||
|
||||
public async Task<string> GetValue(string key, string def = "")
|
||||
{
|
||||
var cookieString = await GetCookie();
|
||||
|
||||
var cookieParts = cookieString.Split(";");
|
||||
|
||||
foreach (var cookiePart in cookieParts)
|
||||
{
|
||||
if(string.IsNullOrEmpty(cookiePart))
|
||||
continue;
|
||||
|
||||
var cookieKeyValue = cookiePart.Split("=");
|
||||
|
||||
if (cookieKeyValue.Length == 2)
|
||||
{
|
||||
if (cookieKeyValue[0] == key)
|
||||
return cookieKeyValue[1];
|
||||
}
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
private async Task SetCookie(string value)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("eval", $"document.cookie = \"{value}\"");
|
||||
}
|
||||
|
||||
private async Task<string> GetCookie()
|
||||
{
|
||||
return await JsRuntime.InvokeAsync<string>("eval", $"document.cookie");
|
||||
}
|
||||
|
||||
private int ExpireDays
|
||||
{
|
||||
set => Expires = DateToUTC(value);
|
||||
}
|
||||
|
||||
private static string DateToUTC(int days) => DateTime.Now.AddDays(days).ToUniversalTime().ToString("R");
|
||||
}
|
55
Moonlight/App/Services/Interop/ToastService.cs
Normal file
55
Moonlight/App/Services/Interop/ToastService.cs
Normal file
|
@ -0,0 +1,55 @@
|
|||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Moonlight.App.Services.Interop;
|
||||
|
||||
public class ToastService
|
||||
{
|
||||
private readonly IJSRuntime JsRuntime;
|
||||
|
||||
public ToastService(IJSRuntime jsRuntime)
|
||||
{
|
||||
JsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task Success(string title, string message, int timeout = 5000)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.toasts.success", title, message, timeout);
|
||||
}
|
||||
|
||||
public async Task Info(string title, string message, int timeout = 5000)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.toasts.info", title, message, timeout);
|
||||
}
|
||||
|
||||
public async Task Danger(string title, string message, int timeout = 5000)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.toasts.danger", title, message, timeout);
|
||||
}
|
||||
|
||||
public async Task Warning(string title, string message, int timeout = 5000)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.toasts.warning", title, message, timeout);
|
||||
}
|
||||
|
||||
// Overloads
|
||||
|
||||
public async Task Success(string message, int timeout = 5000)
|
||||
{
|
||||
await Success("", message, timeout);
|
||||
}
|
||||
|
||||
public async Task Info(string message, int timeout = 5000)
|
||||
{
|
||||
await Info("", message, timeout);
|
||||
}
|
||||
|
||||
public async Task Danger(string message, int timeout = 5000)
|
||||
{
|
||||
await Danger("", message, timeout);
|
||||
}
|
||||
|
||||
public async Task Warning(string message, int timeout = 5000)
|
||||
{
|
||||
await Warning("", message, timeout);
|
||||
}
|
||||
}
|
132
Moonlight/App/Services/Users/UserAuthService.cs
Normal file
132
Moonlight/App/Services/Users/UserAuthService.cs
Normal file
|
@ -0,0 +1,132 @@
|
|||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Helpers;
|
||||
using Moonlight.App.Models.Abstractions;
|
||||
using Moonlight.App.Models.Enums;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services.Utils;
|
||||
using OtpNet;
|
||||
|
||||
namespace Moonlight.App.Services.Users;
|
||||
|
||||
public class UserAuthService
|
||||
{
|
||||
private readonly Repository<User> UserRepository;
|
||||
//private readonly MailService MailService;
|
||||
private readonly JwtService JwtService;
|
||||
private readonly ConfigService ConfigService;
|
||||
|
||||
public UserAuthService(
|
||||
Repository<User> userRepository,
|
||||
//MailService mailService,
|
||||
JwtService jwtService,
|
||||
ConfigService configService)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
//MailService = mailService;
|
||||
JwtService = jwtService;
|
||||
ConfigService = configService;
|
||||
}
|
||||
|
||||
public async Task<User> Register(string username, string email, string password)
|
||||
{
|
||||
// Event though we have form validation i want to
|
||||
// ensure that at least these basic formatting things are done
|
||||
email = email.ToLower().Trim();
|
||||
username = username.ToLower().Trim();
|
||||
|
||||
// Prevent duplication or username and/or email
|
||||
if (UserRepository.Get().Any(x => x.Email == email))
|
||||
throw new DisplayException("A user with that email does already exist");
|
||||
|
||||
if (UserRepository.Get().Any(x => x.Username == username))
|
||||
throw new DisplayException("A user with that username does already exist");
|
||||
|
||||
var user = new User()
|
||||
{
|
||||
Username = username,
|
||||
Email = email,
|
||||
Password = HashHelper.HashToString(password)
|
||||
};
|
||||
|
||||
var result = UserRepository.Add(user);
|
||||
/*
|
||||
await MailService.Send(
|
||||
result,
|
||||
"Welcome {{User.Username}}",
|
||||
"register",
|
||||
result
|
||||
);*/
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task ChangePassword(User user, string newPassword)
|
||||
{
|
||||
user.Password = HashHelper.HashToString(newPassword);
|
||||
user.TokenValidTimestamp = DateTime.UtcNow;
|
||||
UserRepository.Update(user);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SeedTotp(User user)
|
||||
{
|
||||
var key = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20));
|
||||
|
||||
user.TotpKey = key;
|
||||
UserRepository.Update(user);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetTotp(User user, bool state)
|
||||
{
|
||||
// Access to flags without identity service
|
||||
var flags = new FlagStorage(user.Flags);
|
||||
flags[UserFlag.TotpEnabled] = state;
|
||||
user.Flags = flags.RawFlagString;
|
||||
|
||||
if (!state)
|
||||
user.TotpKey = null;
|
||||
|
||||
UserRepository.Update(user);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Mails
|
||||
|
||||
public async Task SendVerification(User user)
|
||||
{
|
||||
var jwt = await JwtService.Create(data =>
|
||||
{
|
||||
data.Add("mailToVerify", user.Email);
|
||||
}, TimeSpan.FromMinutes(10));
|
||||
/*
|
||||
await MailService.Send(user, "Verify your account", "verifyMail", user, new MailVerify()
|
||||
{
|
||||
Url = ConfigService.Get().AppUrl + "/api/verify?token=" + jwt
|
||||
});*/
|
||||
}
|
||||
|
||||
public async Task SendResetPassword(string email)
|
||||
{
|
||||
var user = UserRepository
|
||||
.Get()
|
||||
.FirstOrDefault(x => x.Email == email);
|
||||
|
||||
if (user == null)
|
||||
throw new DisplayException("An account with that email was not found");
|
||||
|
||||
var jwt = await JwtService.Create(data =>
|
||||
{
|
||||
data.Add("accountToReset", user.Id.ToString());
|
||||
});
|
||||
/*
|
||||
await MailService.Send(user, "Password reset for your account", "passwordReset", user, new ResetPassword()
|
||||
{
|
||||
Url = ConfigService.Get().AppUrl + "/api/reset?token=" + jwt
|
||||
});*/
|
||||
}
|
||||
}
|
32
Moonlight/App/Services/Users/UserDetailsService.cs
Normal file
32
Moonlight/App/Services/Users/UserDetailsService.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Repositories;
|
||||
|
||||
namespace Moonlight.App.Services.Users;
|
||||
|
||||
public class UserDetailsService
|
||||
{
|
||||
private readonly BucketService BucketService;
|
||||
private readonly Repository<User> UserRepository;
|
||||
|
||||
public UserDetailsService(BucketService bucketService, Repository<User> userRepository)
|
||||
{
|
||||
BucketService = bucketService;
|
||||
UserRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task UpdateAvatar(User user, Stream stream, string fileName)
|
||||
{
|
||||
var file = await BucketService.Store("avatars", stream, fileName);
|
||||
|
||||
user.Avatar = file;
|
||||
UserRepository.Update(user);
|
||||
}
|
||||
|
||||
public Task UpdateAvatar(User user) // Overload to reset avatar
|
||||
{
|
||||
user.Avatar = null;
|
||||
UserRepository.Update(user);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
50
Moonlight/App/Services/Users/UserService.cs
Normal file
50
Moonlight/App/Services/Users/UserService.cs
Normal file
|
@ -0,0 +1,50 @@
|
|||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Repositories;
|
||||
|
||||
namespace Moonlight.App.Services.Users;
|
||||
|
||||
public class UserService
|
||||
{
|
||||
private readonly Repository<User> UserRepository;
|
||||
private readonly IServiceProvider ServiceProvider;
|
||||
|
||||
public UserAuthService Auth => ServiceProvider.GetRequiredService<UserAuthService>();
|
||||
public UserDetailsService Details => ServiceProvider.GetRequiredService<UserDetailsService>();
|
||||
|
||||
public UserService(
|
||||
Repository<User> userRepository,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
ServiceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public Task Update(User user, string username, string email)
|
||||
{
|
||||
// Event though we have form validation i want to
|
||||
// ensure that at least these basic formatting things are done
|
||||
email = email.ToLower().Trim();
|
||||
username = username.ToLower().Trim();
|
||||
|
||||
// Prevent duplication or username and/or email
|
||||
if (UserRepository.Get().Any(x => x.Email == email && x.Id != user.Id))
|
||||
throw new DisplayException("A user with that email does already exist");
|
||||
|
||||
if (UserRepository.Get().Any(x => x.Username == username && x.Id != user.Id))
|
||||
throw new DisplayException("A user with that username does already exist");
|
||||
|
||||
user.Username = username;
|
||||
user.Email = email;
|
||||
|
||||
UserRepository.Update(user);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Delete(User user)
|
||||
{
|
||||
UserRepository.Delete(user);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
73
Moonlight/App/Services/Utils/JwtService.cs
Normal file
73
Moonlight/App/Services/Utils/JwtService.cs
Normal file
|
@ -0,0 +1,73 @@
|
|||
using JWT.Algorithms;
|
||||
using JWT.Builder;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Moonlight.App.Services.Utils;
|
||||
|
||||
public class JwtService
|
||||
{
|
||||
private readonly ConfigService ConfigService;
|
||||
private readonly TimeSpan DefaultDuration = TimeSpan.FromDays(365 * 10);
|
||||
|
||||
public JwtService(ConfigService configService)
|
||||
{
|
||||
ConfigService = configService;
|
||||
}
|
||||
|
||||
public Task<string> Create(Action<Dictionary<string, string>> data, TimeSpan? validDuration = null)
|
||||
{
|
||||
var builder = new JwtBuilder()
|
||||
.WithSecret(ConfigService.Get().Security.Token)
|
||||
.IssuedAt(DateTime.UtcNow)
|
||||
.ExpirationTime(DateTime.UtcNow.Add(validDuration ?? DefaultDuration))
|
||||
.WithAlgorithm(new HMACSHA512Algorithm());
|
||||
|
||||
var dataDic = new Dictionary<string, string>();
|
||||
data.Invoke(dataDic);
|
||||
|
||||
foreach (var entry in dataDic)
|
||||
builder = builder.AddClaim(entry.Key, entry.Value);
|
||||
|
||||
var jwt = builder.Encode();
|
||||
|
||||
return Task.FromResult(jwt);
|
||||
}
|
||||
|
||||
public Task<bool> Validate(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new JwtBuilder()
|
||||
.WithSecret(ConfigService.Get().Security.Token)
|
||||
.WithAlgorithm(new HMACSHA512Algorithm())
|
||||
.MustVerifySignature()
|
||||
.Decode(token);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Dictionary<string, string>> Decode(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = new JwtBuilder()
|
||||
.WithSecret(ConfigService.Get().Security.Token)
|
||||
.WithAlgorithm(new HMACSHA512Algorithm())
|
||||
.MustVerifySignature()
|
||||
.Decode(token);
|
||||
|
||||
var data = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
||||
|
||||
return Task.FromResult(data)!;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Task.FromResult<Dictionary<string, string>>(null!);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,20 +16,27 @@
|
|||
<ItemGroup>
|
||||
<Folder Include="App\Database\Enums\" />
|
||||
<Folder Include="App\Database\Migrations\" />
|
||||
<Folder Include="App\Http\" />
|
||||
<Folder Include="App\Models\Forms\" />
|
||||
<Folder Include="App\Repositories\" />
|
||||
<Folder Include="App\Http\Middleware\" />
|
||||
<Folder Include="App\Http\Requests\" />
|
||||
<Folder Include="App\Http\Resources\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
||||
<PackageReference Include="JWT" Version="10.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
|
||||
<PackageReference Include="MimeTypes" Version="2.4.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Otp.NET" Version="1.3.0" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="7.0.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.4.3" />
|
||||
<PackageReference Include="Serilog" Version="3.1.0-dev-02078" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0-dev-00923" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -30,8 +30,9 @@
|
|||
|
||||
<component type="typeof(BlazorApp)" render-mode="ServerPrerendered"/>
|
||||
|
||||
<script src="/_framework/blazor.server.js"></script>
|
||||
<script src="/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script src="/js/toaster.js"></script>
|
||||
<script src="/js/moonlight.js"></script>
|
||||
<script src="/_framework/blazor.server.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -2,7 +2,11 @@ using Moonlight.App.Database;
|
|||
using Moonlight.App.Extensions;
|
||||
using Moonlight.App.Helpers;
|
||||
using Moonlight.App.Helpers.LogMigrator;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services;
|
||||
using Moonlight.App.Services.Interop;
|
||||
using Moonlight.App.Services.Users;
|
||||
using Moonlight.App.Services.Utils;
|
||||
using Serilog;
|
||||
|
||||
Directory.CreateDirectory(PathBuilder.Dir("storage"));
|
||||
|
@ -21,8 +25,26 @@ var builder = WebApplication.CreateBuilder(args);
|
|||
|
||||
builder.Services.AddDbContext<DataContext>();
|
||||
|
||||
// Repositories
|
||||
builder.Services.AddScoped(typeof(Repository<>));
|
||||
|
||||
// Services / Utils
|
||||
builder.Services.AddScoped<JwtService>();
|
||||
|
||||
// Services / Interop
|
||||
builder.Services.AddScoped<CookieService>();
|
||||
builder.Services.AddScoped<ToastService>();
|
||||
|
||||
// Services / Users
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<UserAuthService>();
|
||||
builder.Services.AddScoped<UserDetailsService>();
|
||||
|
||||
// Services
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddSingleton<ConfigService>();
|
||||
builder.Services.AddSingleton<SessionService>();
|
||||
builder.Services.AddSingleton<BucketService>();
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
|
|
80
Moonlight/Shared/Components/Auth/Login.razor
Normal file
80
Moonlight/Shared/Components/Auth/Login.razor
Normal file
|
@ -0,0 +1,80 @@
|
|||
@page "/login"
|
||||
@* Virtual route to trick blazor *@
|
||||
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Models.Forms
|
||||
|
||||
@inject IdentityService IdentityService
|
||||
@inject CookieService CookieService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="w-100">
|
||||
<div class="card-body">
|
||||
<div class="text-start mb-8">
|
||||
<h1 class="text-dark mb-3 fs-3x">
|
||||
Sign In
|
||||
</h1>
|
||||
<div class="text-gray-400 fw-semibold fs-6">
|
||||
Get unlimited access & earn money
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
|
||||
<div class="fv-row mb-8">
|
||||
<input @bind="Form.Email" type="text" placeholder="Email" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="fv-row mb-7">
|
||||
<input @bind="Form.Password" type="password" placeholder="Password" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-10">
|
||||
<a href="/reset-password" class="link-primary">
|
||||
Forgot Password ?
|
||||
</a>
|
||||
<a href="/register" class="link-primary">
|
||||
Need an account ?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-stack">
|
||||
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Sign In</button>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="text-gray-400 fw-semibold fs-6 me-3 me-md-6">Or</div>
|
||||
@* OAuth2 Providers here *@
|
||||
</div>
|
||||
</div>
|
||||
</SmartForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private LoginForm Form = new();
|
||||
|
||||
// 2FA
|
||||
private bool Require2FA = false;
|
||||
private string TwoFactorCode = "";
|
||||
|
||||
private async Task OnValidSubmit()
|
||||
{
|
||||
string token;
|
||||
|
||||
try
|
||||
{
|
||||
token = await IdentityService.Login(Form.Email, Form.Password, TwoFactorCode);
|
||||
}
|
||||
catch (ArgumentNullException) // IdentityService requires two factor code => show field
|
||||
{
|
||||
Require2FA = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await CookieService.SetValue("token", token);
|
||||
await IdentityService.Authenticate(token);
|
||||
|
||||
if (Navigation.Uri.EndsWith("/login"))
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
}
|
78
Moonlight/Shared/Components/Auth/Register.razor
Normal file
78
Moonlight/Shared/Components/Auth/Register.razor
Normal file
|
@ -0,0 +1,78 @@
|
|||
@page "/register"
|
||||
@* Virtual route to trick blazor *@
|
||||
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Models.Forms
|
||||
@using Moonlight.App.Services.Users
|
||||
@using Moonlight.App.Exceptions
|
||||
|
||||
@inject IdentityService IdentityService
|
||||
@inject UserService UserService
|
||||
@inject CookieService CookieService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<div class="w-100">
|
||||
<div class="card-body">
|
||||
<div class="text-start mb-8">
|
||||
<h1 class="text-dark mb-3 fs-3x">
|
||||
Sign Up
|
||||
</h1>
|
||||
<div class="text-gray-400 fw-semibold fs-6">
|
||||
Get unlimited access & earn money
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
|
||||
<div class="fv-row mb-8">
|
||||
<input @bind="Form.Username" type="text" placeholder="Username" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="fv-row mb-8">
|
||||
<input @bind="Form.Email" type="text" placeholder="Email" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="fv-row mb-7">
|
||||
<input @bind="Form.Password" type="password" placeholder="Password" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="fv-row mb-7">
|
||||
<input @bind="Form.RepeatedPassword" type="password" placeholder="Repeat your password" class="form-control form-control-solid">
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-10">
|
||||
<div></div>
|
||||
<a href="/login" class="link-primary">
|
||||
Already have an account ?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-stack">
|
||||
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Sign Up</button>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="text-gray-400 fw-semibold fs-6 me-3 me-md-6">Or</div>
|
||||
@* OAuth2 Providers here *@
|
||||
</div>
|
||||
</div>
|
||||
</SmartForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private RegisterForm Form = new();
|
||||
|
||||
private async Task OnValidSubmit()
|
||||
{
|
||||
if (Form.Password != Form.RepeatedPassword)
|
||||
throw new DisplayException("The passwords do not match");
|
||||
|
||||
var user = await UserService.Auth.Register(Form.Username, Form.Email, Form.Password);
|
||||
var token = await IdentityService.GenerateToken(user);
|
||||
|
||||
await CookieService.SetValue("token", token);
|
||||
await IdentityService.Authenticate(token);
|
||||
|
||||
if (Navigation.Uri.EndsWith("/register"))
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
}
|
57
Moonlight/Shared/Components/Forms/SmartFileSelect.razor
Normal file
57
Moonlight/Shared/Components/Forms/SmartFileSelect.razor
Normal file
|
@ -0,0 +1,57 @@
|
|||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
@inject ToastService ToastService
|
||||
|
||||
<InputFile OnChange="OnFileChanged" type="file" id="fileUpload" hidden=""/>
|
||||
<label for="fileUpload" class="">
|
||||
@if (SelectedFile != null)
|
||||
{
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control disabled" value="@(SelectedFile.Name)" disabled="">
|
||||
<button class="btn btn-danger" type="button" @onclick="RemoveSelection">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="btn btn-primary me-3 btn-icon">
|
||||
<i class="bx bx-upload"></i>
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public IBrowserFile? SelectedFile { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int MaxFileSize { get; set; } = 1024 * 1024 * 5;
|
||||
|
||||
private async Task OnFileChanged(InputFileChangeEventArgs arg)
|
||||
{
|
||||
if (arg.FileCount > 0)
|
||||
{
|
||||
if (arg.File.Size < 1024 * 1024 * 5)
|
||||
{
|
||||
SelectedFile = arg.File;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await ToastService.Danger("The uploaded file should not be bigger than 5MB");
|
||||
}
|
||||
|
||||
SelectedFile = null;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public async Task RemoveSelection()
|
||||
{
|
||||
SelectedFile = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
132
Moonlight/Shared/Components/Forms/SmartForm.razor
Normal file
132
Moonlight/Shared/Components/Forms/SmartForm.razor
Normal file
|
@ -0,0 +1,132 @@
|
|||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Moonlight.App.Exceptions
|
||||
|
||||
<div class="form @CssClass">
|
||||
<EditForm @ref="EditForm" Model="Model" OnValidSubmit="ValidSubmit" OnInvalidSubmit="InvalidSubmit">
|
||||
<DataAnnotationsValidator></DataAnnotationsValidator>
|
||||
@if (Working)
|
||||
{
|
||||
<div class="d-flex flex-center flex-column">
|
||||
<span class="fs-1 spinner-border spinner-border-lg align-middle me-2"></span>
|
||||
<span class="mt-3 fs-5">Proccessing</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ErrorMessages.Any())
|
||||
{
|
||||
<div class="alert alert-danger bg-danger text-white p-10 mb-3">
|
||||
@foreach (var msg in ErrorMessages)
|
||||
{
|
||||
<TL>@(msg)</TL>
|
||||
<br/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@(ChildContent)
|
||||
}
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public object Model { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Task>? OnValidSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Task>? OnInvalidSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Task>? OnSubmit { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string CssClass { get; set; }
|
||||
|
||||
private EditForm EditForm;
|
||||
|
||||
private List<string> ErrorMessages = new();
|
||||
|
||||
private bool Working = false;
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
//EditForm.EditContext!.SetFieldCssClassProvider(new FieldCssHelper());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidSubmit(EditContext context)
|
||||
{
|
||||
ErrorMessages.Clear();
|
||||
Working = true;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OnValidSubmit != null)
|
||||
await OnValidSubmit.Invoke();
|
||||
|
||||
if (OnSubmit != null)
|
||||
await OnSubmit.Invoke();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e is DisplayException displayException)
|
||||
{
|
||||
ErrorMessages.Add(displayException.Message);
|
||||
}
|
||||
else
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
Working = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task InvalidSubmit(EditContext context)
|
||||
{
|
||||
ErrorMessages.Clear();
|
||||
context.Validate();
|
||||
|
||||
foreach (var message in context.GetValidationMessages())
|
||||
{
|
||||
ErrorMessages.Add(message);
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
if (OnInvalidSubmit != null)
|
||||
await OnInvalidSubmit.Invoke();
|
||||
|
||||
if (OnSubmit != null)
|
||||
await OnSubmit.Invoke();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e is DisplayException displayException)
|
||||
{
|
||||
ErrorMessages.Add(displayException.Message);
|
||||
}
|
||||
else
|
||||
throw e;
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<div class="card mb-5 mb-xl-10">
|
||||
<div class="card-body pt-0 pb-0">
|
||||
<ul class="nav nav-stretch nav-line-tabs nav-line-tabs-2x border-transparent fs-5 fw-bold">
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 0 ? "active" : "")" href="/account">
|
||||
General
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mt-2">
|
||||
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 1 ? "active" : "")" href="/account/security">
|
||||
Security
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public int Index { get; set; }
|
||||
}
|
|
@ -1,6 +1,11 @@
|
|||
@using Moonlight.Shared.Layouts
|
||||
<div id="kt_app_header" class="app-header ">
|
||||
<div class="app-container container-fluid d-flex align-items-stretch flex-stack " id="kt_app_header_container">
|
||||
@using Moonlight.App.Services
|
||||
|
||||
@inject IdentityService IdentityService
|
||||
@inject CookieService CookieService
|
||||
|
||||
<div class="app-header">
|
||||
<div class="app-container container-fluid d-flex align-items-stretch flex-stack">
|
||||
<div class="d-flex align-items-center d-block d-lg-none ms-n3" title="Show sidebar menu">
|
||||
<a href="#" @onclick="Layout.ToggleMobileSidebar" @onclick:preventDefault class="btn btn-icon btn-active-color-primary w-35px h-35px me-2">
|
||||
<i class="bx bx-sm bx-menu"></i>
|
||||
|
@ -9,7 +14,7 @@
|
|||
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/demo38-small.svg" class="h-30px">
|
||||
</a>
|
||||
</div>
|
||||
<div class="app-navbar flex-lg-grow-1" id="kt_app_header_navbar">
|
||||
<div class="app-navbar flex-lg-grow-1">
|
||||
<div class="app-navbar-item d-flex align-items-stretch flex-lg-grow-1">
|
||||
</div>
|
||||
|
||||
|
@ -19,7 +24,14 @@
|
|||
|
||||
<div class="app-navbar-item ms-5 ms-md-5">
|
||||
<div class="cursor-pointer symbol symbol-circle symbol-35px symbol-md-45px" id="dropdownMenuLink" data-bs-toggle="dropdown">
|
||||
<img src="https://endelon-hosting.de/assets/img/logo.svg" alt="user">
|
||||
@if (IdentityService.CurrentUser.Avatar == null)
|
||||
{
|
||||
<img src="/assets/img/avatar.png" alt="Avatar">
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="/api/bucket/avatars/@(IdentityService.CurrentUser.Avatar)" alt="Avatar">
|
||||
}
|
||||
</div>
|
||||
<div class="dropdown-menu dropdown-menu-end menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg menu-state-color fw-semibold py-4 fs-6 w-275px" aria-labelledby="dropdownMenuLink">
|
||||
<div class="menu-item px-3">
|
||||
|
@ -29,178 +41,23 @@
|
|||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="fw-bold d-flex align-items-center fs-5">
|
||||
Max Smith <span class="badge badge-light-success fw-bold fs-8 px-2 py-1 ms-2">Pro</span>
|
||||
@(IdentityService.CurrentUser.Username)
|
||||
</div>
|
||||
<a href="#" class="fw-semibold text-muted text-hover-primary fs-7">
|
||||
max@kt.com
|
||||
</a>
|
||||
<span class="fw-semibold text-muted fs-7">
|
||||
@(IdentityService.CurrentUser.Email)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="separator my-2"></div>
|
||||
<div class="menu-item px-5">
|
||||
<a href="/metronic8/demo38/../demo38/account/overview.html" class="menu-link px-5">
|
||||
My Profile
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-5">
|
||||
<a href="/metronic8/demo38/../demo38/apps/projects/list.html" class="menu-link px-5">
|
||||
<span class="menu-text">My Projects</span>
|
||||
<span class="menu-badge">
|
||||
<span class="badge badge-light-danger badge-circle fw-bold fs-7">3</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
|
||||
<a href="#" class="menu-link px-5">
|
||||
<span class="menu-title">My Subscription</span>
|
||||
<span class="menu-arrow"></span>
|
||||
</a>
|
||||
<div class="menu-sub menu-sub-dropdown w-175px py-4">
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/referrals.html" class="menu-link px-5">
|
||||
Referrals
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/billing.html" class="menu-link px-5">
|
||||
Billing
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link px-5">
|
||||
Payments
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link d-flex flex-stack px-5">
|
||||
Statements
|
||||
<span class="ms-2 lh-0" data-bs-toggle="tooltip" aria-label="View your statements" data-bs-original-title="View your statements" data-kt-initialized="1">
|
||||
<i class="ki-outline ki-information-5 fs-5"></i>
|
||||
</span>
|
||||
<a href="/account" class="menu-link px-5">
|
||||
Account
|
||||
</a>
|
||||
</div>
|
||||
<div class="separator my-2"></div>
|
||||
<div class="menu-item px-3">
|
||||
<div class="menu-content px-3">
|
||||
<label class="form-check form-switch form-check-custom form-check-solid">
|
||||
<input class="form-check-input w-30px h-20px" type="checkbox" value="1" checked="checked" name="notifications">
|
||||
<span class="form-check-label text-muted fs-7">
|
||||
Notifications
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item px-5">
|
||||
<a href="/metronic8/demo38/../demo38/account/statements.html" class="menu-link px-5">
|
||||
My Statements
|
||||
</a>
|
||||
</div>
|
||||
<div class="separator my-2"></div>
|
||||
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
|
||||
<a href="#" class="menu-link px-5">
|
||||
<span class="menu-title position-relative">
|
||||
Mode
|
||||
|
||||
<span class="ms-5 position-absolute translate-middle-y top-50 end-0">
|
||||
<i class="ki-outline ki-night-day theme-light-show fs-2"></i> <i class="ki-outline ki-moon theme-dark-show fs-2"></i>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-title-gray-700 menu-icon-gray-500 menu-active-bg menu-state-color fw-semibold py-4 fs-base w-150px" data-kt-menu="true" data-kt-element="theme-mode-menu">
|
||||
<div class="menu-item px-3 my-0">
|
||||
<a href="#" class="menu-link px-3 py-2" data-kt-element="mode" data-kt-value="light">
|
||||
<span class="menu-icon" data-kt-element="icon">
|
||||
<i class="ki-outline ki-night-day fs-2"></i>
|
||||
</span>
|
||||
<span class="menu-title">
|
||||
Light
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3 my-0">
|
||||
<a href="#" class="menu-link px-3 py-2 active" data-kt-element="mode" data-kt-value="dark">
|
||||
<span class="menu-icon" data-kt-element="icon">
|
||||
<i class="ki-outline ki-moon fs-2"></i>
|
||||
</span>
|
||||
<span class="menu-title">
|
||||
Dark
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3 my-0">
|
||||
<a href="#" class="menu-link px-3 py-2" data-kt-element="mode" data-kt-value="system">
|
||||
<span class="menu-icon" data-kt-element="icon">
|
||||
<i class="ki-outline ki-screen fs-2"></i>
|
||||
</span>
|
||||
<span class="menu-title">
|
||||
System
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item px-5" data-kt-menu-trigger="{default: 'click', lg: 'hover'}" data-kt-menu-placement="left-start" data-kt-menu-offset="-15px, 0">
|
||||
<a href="#" class="menu-link px-5">
|
||||
<span class="menu-title position-relative">
|
||||
Language
|
||||
<span class="fs-8 rounded bg-light px-3 py-2 position-absolute translate-middle-y top-50 end-0">
|
||||
English <img class="w-15px h-15px rounded-1 ms-2" src="/metronic8/demo38/assets/media/flags/united-states.svg" alt="">
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="menu-sub menu-sub-dropdown w-175px py-4">
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5 active">
|
||||
<span class="symbol symbol-20px me-4">
|
||||
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/united-states.svg" alt="">
|
||||
</span>
|
||||
English
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
|
||||
<span class="symbol symbol-20px me-4">
|
||||
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/spain.svg" alt="">
|
||||
</span>
|
||||
Spanish
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
|
||||
<span class="symbol symbol-20px me-4">
|
||||
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/germany.svg" alt="">
|
||||
</span>
|
||||
German
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
|
||||
<span class="symbol symbol-20px me-4">
|
||||
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/japan.svg" alt="">
|
||||
</span>
|
||||
Japanese
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-3">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link d-flex px-5">
|
||||
<span class="symbol symbol-20px me-4">
|
||||
<img class="rounded-1" src="/metronic8/demo38/assets/media/flags/france.svg" alt="">
|
||||
</span>
|
||||
French
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item px-5 my-1">
|
||||
<a href="/metronic8/demo38/../demo38/account/settings.html" class="menu-link px-5">
|
||||
Account Settings
|
||||
</a>
|
||||
</div>
|
||||
<div class="menu-item px-5">
|
||||
<a href="/metronic8/demo38/../demo38/authentication/layouts/corporate/sign-in.html" class="menu-link px-5">
|
||||
<a href="#" @onclick="Logout" @onclick:preventDefault class="menu-link px-5">
|
||||
Sign Out
|
||||
</a>
|
||||
</div>
|
||||
|
@ -216,4 +73,10 @@
|
|||
{
|
||||
[CascadingParameter]
|
||||
public DefaultLayout Layout { get; set; }
|
||||
|
||||
private async Task Logout()
|
||||
{
|
||||
await IdentityService.Authenticate("");
|
||||
await CookieService.SetValue("token", "");
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
@using System.Diagnostics
|
||||
@using Moonlight.App.Exceptions
|
||||
|
||||
@inherits ErrorBoundaryBase
|
||||
|
||||
@if (Crashed)
|
||||
|
|
|
@ -25,7 +25,6 @@
|
|||
{
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; }
|
||||
|
||||
public bool ShowMobileSidebar { get; set; }
|
||||
|
||||
public async Task ToggleMobileSidebar()
|
||||
|
|
|
@ -1,7 +1,132 @@
|
|||
@inherits LayoutComponentBase
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Models.Abstractions
|
||||
@using Moonlight.App.Models.Enums
|
||||
@using Moonlight.Shared.Components.Auth
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
|
||||
@inject CookieService CookieService
|
||||
@inject ConfigService ConfigService
|
||||
@inject IdentityService IdentityService
|
||||
@inject SessionService SessionService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@{
|
||||
var url = new Uri(Navigation.Uri);
|
||||
}
|
||||
|
||||
@if (Initialized)
|
||||
{
|
||||
if (IdentityService.IsSignedIn)
|
||||
{
|
||||
if (!IdentityService.Flags[UserFlag.MailVerified] && ConfigService.Get().Security.EnableEmailVerify)
|
||||
{
|
||||
}
|
||||
else if (IdentityService.Flags[UserFlag.PasswordPending])
|
||||
{
|
||||
}
|
||||
else
|
||||
{
|
||||
<DefaultLayout>
|
||||
<SoftErrorHandler>
|
||||
@Body
|
||||
</SoftErrorHandler>
|
||||
</DefaultLayout>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (url.LocalPath == "/register")
|
||||
{
|
||||
<OverlayLayout>
|
||||
<Register />
|
||||
</OverlayLayout>
|
||||
}
|
||||
else
|
||||
{
|
||||
<OverlayLayout>
|
||||
<Login />
|
||||
</OverlayLayout>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<OverlayLayout>
|
||||
<div class="w-100">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-dark mb-3 fs-4">
|
||||
Connecting to the remote server
|
||||
</h1>
|
||||
<div class="text-gray-400 fw-semibold fs-6">
|
||||
<div class="text-success">
|
||||
<div class="spinner-border me-2" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayLayout>
|
||||
}
|
||||
|
||||
@code
|
||||
{
|
||||
private bool Initialized = false;
|
||||
|
||||
private Session? MySession;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
IdentityService.OnAuthenticationStateChanged += async (_, _) =>
|
||||
{
|
||||
if (MySession != null)
|
||||
{
|
||||
MySession.User = IdentityService.CurrentUserNullable;
|
||||
MySession.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
};
|
||||
|
||||
Navigation.LocationChanged += (_, _) =>
|
||||
{
|
||||
if (MySession != null)
|
||||
{
|
||||
MySession.Url = Navigation.Uri;
|
||||
MySession.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
var token = await CookieService.GetValue("token");
|
||||
await IdentityService.Authenticate(token);
|
||||
|
||||
MySession = new()
|
||||
{
|
||||
//Ip = ConnectionService.GetIp(), TODO: Implement
|
||||
Url = Navigation.Uri,
|
||||
User = IdentityService.CurrentUserNullable
|
||||
};
|
||||
|
||||
await SessionService.Register(MySession);
|
||||
|
||||
Initialized = true;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
public async void Dispose() // This method will be called if the user closes his tab
|
||||
{
|
||||
if (MySession != null)
|
||||
await SessionService.Unregister(MySession);
|
||||
}
|
||||
}
|
27
Moonlight/Shared/Layouts/OverlayLayout.razor
Normal file
27
Moonlight/Shared/Layouts/OverlayLayout.razor
Normal file
|
@ -0,0 +1,27 @@
|
|||
<div class="d-flex flex-column flex-root" id="kt_app_root">
|
||||
<div class="d-flex flex-column flex-lg-row flex-column-fluid">
|
||||
<a href="/" class="d-block d-lg-none mx-auto py-20">
|
||||
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/default.svg" class="theme-light-show h-25px">
|
||||
<img alt="Logo" src="/metronic8/demo38/assets/media/logos/default-dark.svg" class="theme-dark-show h-25px">
|
||||
</a>
|
||||
<div class="d-flex flex-column flex-column-fluid flex-center w-lg-50 p-10">
|
||||
<div class="d-flex justify-content-between flex-column-fluid flex-column w-100 mw-450px">
|
||||
<div class="d-flex flex-stack py-2">
|
||||
</div>
|
||||
<div class="py-20">
|
||||
@ChildContent
|
||||
</div>
|
||||
<div class="m-0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-flex flex-lg-row-fluid w-50 bgi-size-cover bgi-position-y-center bgi-position-x-start bgi-no-repeat" style="background-image: url(/metronic8/demo38/assets/media/auth/bg11.png)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public RenderFragment ChildContent { get; set; }
|
||||
}
|
135
Moonlight/Shared/Views/Account/Index.razor
Normal file
135
Moonlight/Shared/Views/Account/Index.razor
Normal file
|
@ -0,0 +1,135 @@
|
|||
@page "/account"
|
||||
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Services.Users
|
||||
@using Moonlight.App.Models.Forms
|
||||
|
||||
@inject IdentityService IdentityService
|
||||
@inject UserService UserService
|
||||
@inject ToastService ToastService
|
||||
|
||||
<AccountNavigation Index="0"/>
|
||||
|
||||
<div class="card mt-5">
|
||||
<div class="card-body">
|
||||
<div class="row justify-content-between align-items-center">
|
||||
<div class="col">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-auto">
|
||||
<div class="symbol symbol-circle symbol-35px symbol-md-45px">
|
||||
@if (IdentityService.CurrentUser.Avatar == null)
|
||||
{
|
||||
<img src="/assets/img/avatar.png" alt="Avatar">
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="/api/bucket/avatars/@(IdentityService.CurrentUser.Avatar)" alt="Avatar">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col ms-n2">
|
||||
<h4 class="mb-1">
|
||||
Your avatar
|
||||
</h4>
|
||||
<small class="text-body-secondary">
|
||||
PNG or JPG no bigger than 1000px wide and tall.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center">
|
||||
<SmartFileSelect @ref="SmartFileSelect"/>
|
||||
<WButton OnClick="OnUpload" Text="Upload" CssClasses="ms-2 btn btn-primary" WorkingText="Uploading"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-5">
|
||||
<SmartForm Model="Form" OnValidSubmit="OnSubmit">
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<label class="form-label">Username</label>
|
||||
<input @bind="Form.Username" class="form-control form-control-solid" type="text" />
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label class="form-label">Email</label>
|
||||
<input @bind="Form.Email" class="form-control form-control-solid" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</SmartForm>
|
||||
</div>
|
||||
|
||||
<div class="card card-body mt-5">
|
||||
<div class="text-end">
|
||||
<ConfirmButton OnClick="OnDelete" Text="Delete account" CssClasses="btn btn-danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@code
|
||||
{
|
||||
private UpdateAccountForm Form = new();
|
||||
private SmartFileSelect SmartFileSelect;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Form.Username = IdentityService.CurrentUser.Username;
|
||||
Form.Email = IdentityService.CurrentUser.Email;
|
||||
}
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
await UserService.Update(
|
||||
IdentityService.CurrentUser,
|
||||
Form.Username,
|
||||
Form.Email
|
||||
);
|
||||
|
||||
await ToastService.Success("Successfully saved changes");
|
||||
}
|
||||
|
||||
private async Task OnUpload()
|
||||
{
|
||||
// Validation
|
||||
if (SmartFileSelect.SelectedFile == null)
|
||||
return;
|
||||
|
||||
if (!Formatter.EndsInOneOf(SmartFileSelect.SelectedFile.Name, new[]
|
||||
{
|
||||
".jpg",
|
||||
".png"
|
||||
}))
|
||||
{
|
||||
await ToastService.Danger("You are only allowed to upload JPG and PNG files");
|
||||
return;
|
||||
}
|
||||
|
||||
// Now, we are actually working
|
||||
await UserService.Details.UpdateAvatar(
|
||||
IdentityService.CurrentUser,
|
||||
SmartFileSelect.SelectedFile.OpenReadStream(SmartFileSelect.MaxFileSize),
|
||||
SmartFileSelect.SelectedFile.Name
|
||||
);
|
||||
|
||||
// Reset
|
||||
await SmartFileSelect.RemoveSelection();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
await ToastService.Success("Successfully uploaded avatar");
|
||||
}
|
||||
|
||||
private async Task OnDelete()
|
||||
{
|
||||
await UserService.Delete(IdentityService.CurrentUser);
|
||||
await IdentityService.Authenticate();
|
||||
}
|
||||
}
|
158
Moonlight/Shared/Views/Account/Security.razor
Normal file
158
Moonlight/Shared/Views/Account/Security.razor
Normal file
|
@ -0,0 +1,158 @@
|
|||
@page "/account/security"
|
||||
|
||||
@using Moonlight.App.Models.Forms
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Services.Users
|
||||
@using OtpNet
|
||||
@using QRCoder
|
||||
@using Moonlight.App.Models.Enums
|
||||
|
||||
@inject IdentityService IdentityService
|
||||
@inject UserService UserService
|
||||
@inject ToastService ToastService
|
||||
|
||||
<AccountNavigation Index="1"/>
|
||||
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="card">
|
||||
<SmartForm Model="PasswordForm" OnValidSubmit="OnPasswordSubmit">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div>
|
||||
<label class="form-label">New password</label>
|
||||
<input @bind="PasswordForm.Password" type="password" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div>
|
||||
<label class="form-label">New password repeated</label>
|
||||
<input @bind="PasswordForm.RepeatedPassword" type="password" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">Update password</button>
|
||||
</div>
|
||||
</div>
|
||||
</SmartForm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
@if (IdentityService.Flags[UserFlag.TotpEnabled])
|
||||
{
|
||||
<div class="card card-body fs-6">
|
||||
<span class="card-title text-success">Your account is secured with 2fa</span>
|
||||
<div class="text-center">
|
||||
<ConfirmButton OnClick="OnDisable2FA" Text="Disable 2fa" CssClasses="btn btn-danger" WorkingText="Disabling"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!IdentityService.Flags[UserFlag.TotpEnabled] && IdentityService.CurrentUser.TotpKey != null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body fs-6">
|
||||
<p>
|
||||
Scan the qr code and enter the code generated by the app you have scanned it in
|
||||
</p>
|
||||
@{
|
||||
QRCodeGenerator qrGenerator = new QRCodeGenerator();
|
||||
|
||||
var qrCodeData = qrGenerator.CreateQrCode
|
||||
(
|
||||
$"otpauth://totp/{Uri.EscapeDataString(IdentityService.CurrentUser.Email)}?secret={IdentityService.CurrentUser.TotpKey}&issuer={Uri.EscapeDataString("Moonlight")}",
|
||||
QRCodeGenerator.ECCLevel.Q
|
||||
);
|
||||
|
||||
PngByteQRCode qrCode = new PngByteQRCode(qrCodeData);
|
||||
byte[] qrCodeAsPngByteArr = qrCode.GetGraphic(5);
|
||||
var base64 = Convert.ToBase64String(qrCodeAsPngByteArr);
|
||||
}
|
||||
<div class="text-center">
|
||||
<img src="data:image/png;base64,@(base64)" alt="QR Code" class="img-fluid">
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<span class="h3">@(IdentityService.CurrentUser.TotpKey)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<SmartForm Model="CodeForm" OnValidSubmit="On2FASubmit">
|
||||
<div class="input-group">
|
||||
<input @bind="CodeForm.Code" type="number" class="form-control"/>
|
||||
<button type="submit" class="btn btn-primary">Enable 2fa</button>
|
||||
</div>
|
||||
</SmartForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card card-body fs-6">
|
||||
<span class="card-title">Secure your account using 2fa</span>
|
||||
<p>
|
||||
Make sure you have installed one of the following apps on your smartphone and continue
|
||||
</p>
|
||||
|
||||
<a href="https://support.google.com/accounts/answer/1066447?hl=en" target="_blank">Google Authenticator</a>
|
||||
<br/>
|
||||
<a href="https://www.microsoft.com/en-us/account/authenticator" target="_blank">Microsoft Authenticator</a>
|
||||
<br/>
|
||||
<a href="https://authy.com/download/" target="_blank">Authy</a>
|
||||
<br/>
|
||||
<a href="https://support.1password.com/one-time-passwords/" target="_blank">1Password</a>
|
||||
<br/>
|
||||
<div class="text-center">
|
||||
<WButton OnClick="OnSeed2FA" Text="Enable 2fa" CssClasses="btn btn-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
private UpdateAccountPasswordForm PasswordForm = new();
|
||||
private TwoFactorCodeForm CodeForm = new();
|
||||
|
||||
private async Task OnPasswordSubmit()
|
||||
{
|
||||
if (PasswordForm.Password != PasswordForm.RepeatedPassword)
|
||||
throw new DisplayException("The passwords do not match");
|
||||
|
||||
await UserService.Auth.ChangePassword(IdentityService.CurrentUser, PasswordForm.Password);
|
||||
await ToastService.Success("Successfully updated your password");
|
||||
await IdentityService.Authenticate();
|
||||
}
|
||||
|
||||
private async Task OnDisable2FA()
|
||||
{
|
||||
await UserService.Auth.SetTotp(IdentityService.CurrentUser, false);
|
||||
await IdentityService.Authenticate();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnSeed2FA()
|
||||
{
|
||||
await UserService.Auth.SeedTotp(IdentityService.CurrentUser);
|
||||
await IdentityService.Authenticate();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task On2FASubmit()
|
||||
{
|
||||
var totp = new Totp(Base32Encoding.ToBytes(IdentityService.CurrentUser.TotpKey));
|
||||
var code = totp.ComputeTotp();
|
||||
|
||||
if (code != CodeForm.Code)
|
||||
throw new DisplayException("The 2fa code you entered is invalid");
|
||||
|
||||
CodeForm = new();
|
||||
|
||||
await UserService.Auth.SetTotp(IdentityService.CurrentUser, true);
|
||||
await IdentityService.Authenticate();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
|
@ -5,3 +5,6 @@
|
|||
@using Moonlight.App.Helpers
|
||||
@using Moonlight.Shared.Components.Partials
|
||||
@using Moonlight.Shared.Components.Forms
|
||||
@using Moonlight.Shared.Components.Navigations
|
||||
@using Moonlight.App.Services.Interop
|
||||
@using Moonlight.App.Exceptions
|
25
Moonlight/wwwroot/js/moonlight.js
vendored
Normal file
25
Moonlight/wwwroot/js/moonlight.js
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
window.moonlight = {
|
||||
toasts: {
|
||||
success: function(title, message, timeout)
|
||||
{
|
||||
this.show(title, message, timeout, "success");
|
||||
},
|
||||
danger: function(title, message, timeout)
|
||||
{
|
||||
this.show(title, message, timeout, "danger");
|
||||
},
|
||||
warning: function(title, message, timeout)
|
||||
{
|
||||
this.show(title, message, timeout, "warning");
|
||||
},
|
||||
info: function(title, message, timeout)
|
||||
{
|
||||
this.show(title, message, timeout, "info");
|
||||
},
|
||||
show: function(title, message, timeout, color)
|
||||
{
|
||||
var toast = new ToastHelper(title, message, color, timeout);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
}
|
97
Moonlight/wwwroot/js/toaster.js
vendored
Normal file
97
Moonlight/wwwroot/js/toaster.js
vendored
Normal file
|
@ -0,0 +1,97 @@
|
|||
class ToastHelper {
|
||||
constructor(title, description, color, timeout) {
|
||||
var toastElement = buildToast(title, description, color);
|
||||
var toastWrapper = getOrCreateToastWrapper();
|
||||
toastWrapper.append(toastElement);
|
||||
this.bootstrapToast = new bootstrap.Toast(toastElement);
|
||||
|
||||
this.show = function () {
|
||||
this.bootstrapToast.show();
|
||||
|
||||
if (timeout && typeof timeout === 'number') {
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
toastElement.remove();
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
this.hide = function () {
|
||||
this.bootstrapToast.hide();
|
||||
}
|
||||
|
||||
this.dispose = function () {
|
||||
this.bootstrapToast.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateToastWrapper() {
|
||||
var toastWrapper = document.querySelector('body > [data-toast-wrapper]');
|
||||
|
||||
if (!toastWrapper) {
|
||||
toastWrapper = document.createElement('div');
|
||||
toastWrapper.style.zIndex = 11;
|
||||
toastWrapper.style.position = 'fixed';
|
||||
toastWrapper.style.bottom = 0;
|
||||
toastWrapper.style.right = 0;
|
||||
toastWrapper.style.padding = '1rem';
|
||||
toastWrapper.setAttribute('data-toast-wrapper', '');
|
||||
document.body.append(toastWrapper);
|
||||
}
|
||||
|
||||
return toastWrapper;
|
||||
}
|
||||
|
||||
function buildToastHeader(title, color) {
|
||||
var toastHeader = document.createElement('div');
|
||||
|
||||
if(title !== "")
|
||||
{
|
||||
toastHeader.setAttribute('class', 'toast-header');
|
||||
|
||||
var strong = document.createElement('strong');
|
||||
strong.setAttribute('class', 'me-auto ' + (color ? 'text-' + color : ''));
|
||||
strong.textContent = title;
|
||||
|
||||
var closeButton = document.createElement('button');
|
||||
closeButton.setAttribute('type', 'button');
|
||||
closeButton.setAttribute('class', 'btn-close');
|
||||
closeButton.setAttribute('data-bs-dismiss', 'toast');
|
||||
closeButton.setAttribute('data-label', 'Close');
|
||||
|
||||
toastHeader.append(strong);
|
||||
toastHeader.append(closeButton);
|
||||
}
|
||||
|
||||
return toastHeader;
|
||||
}
|
||||
|
||||
function buildToastBody(title, description, color) {
|
||||
var toastBody = document.createElement('div');
|
||||
|
||||
if(title !== "")
|
||||
toastBody.setAttribute('class', 'toast-body fs-5');
|
||||
else
|
||||
toastBody.setAttribute('class', 'toast-body fs-5 fw-bold ' + (color ? 'text-' + color : ''));
|
||||
|
||||
toastBody.textContent = description;
|
||||
|
||||
return toastBody;
|
||||
}
|
||||
|
||||
function buildToast(title, description, color) {
|
||||
var toast = document.createElement('div');
|
||||
toast.setAttribute('class', 'toast my-2');
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
|
||||
var toastHeader = buildToastHeader(title, color);
|
||||
var toastBody = buildToastBody(title, description, color);
|
||||
|
||||
toast.append(toastHeader);
|
||||
toast.append(toastBody);
|
||||
|
||||
return toast;
|
||||
}
|
Loading…
Reference in a new issue