forked from Kieran/void.cat
Add email verification
Standardize button styles
This commit is contained in:
parent
c2c6b92ce6
commit
72823ffedd
@ -4,66 +4,110 @@ using VoidCat.Services.Abstractions;
|
|||||||
|
|
||||||
namespace VoidCat.Controllers;
|
namespace VoidCat.Controllers;
|
||||||
|
|
||||||
[Route("user")]
|
[Route("user/{id}")]
|
||||||
public class UserController : Controller
|
public class UserController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUserStore _store;
|
private readonly IUserStore _store;
|
||||||
private readonly IUserUploadsStore _userUploads;
|
private readonly IUserUploadsStore _userUploads;
|
||||||
|
private readonly IEmailVerification _emailVerification;
|
||||||
|
|
||||||
public UserController(IUserStore store, IUserUploadsStore userUploads)
|
public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_userUploads = userUploads;
|
_userUploads = userUploads;
|
||||||
|
_emailVerification = emailVerification;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("{id}")]
|
[Route("")]
|
||||||
public async Task<VoidUser?> GetUser([FromRoute] string id)
|
public async Task<IActionResult> GetUser([FromRoute] string id)
|
||||||
{
|
{
|
||||||
var loggedUser = HttpContext.GetUserId();
|
var loggedUser = HttpContext.GetUserId();
|
||||||
var requestedId = id.FromBase58Guid();
|
var requestedId = id.FromBase58Guid();
|
||||||
if (loggedUser == requestedId)
|
if (loggedUser == requestedId)
|
||||||
{
|
{
|
||||||
return await _store.Get<PrivateVoidUser>(id.FromBase58Guid());
|
return Json(await _store.Get<PrivateVoidUser>(id.FromBase58Guid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _store.Get<PublicVoidUser>(id.FromBase58Guid());
|
var user = await _store.Get<PublicVoidUser>(id.FromBase58Guid());
|
||||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return default;
|
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return NotFound();
|
||||||
|
|
||||||
return user;
|
return Json(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("{id}")]
|
[Route("")]
|
||||||
public async Task<IActionResult> UpdateUser([FromRoute] string id, [FromBody] PublicVoidUser user)
|
public async Task<IActionResult> UpdateUser([FromRoute] string id, [FromBody] PublicVoidUser user)
|
||||||
{
|
{
|
||||||
var loggedUser = HttpContext.GetUserId();
|
var loggedUser = await GetAuthorizedUser(id);
|
||||||
var requestedId = id.FromBase58Guid();
|
if (loggedUser == default) return Unauthorized();
|
||||||
if (requestedId != loggedUser)
|
|
||||||
{
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
// check requested user is same as user obj
|
|
||||||
if (requestedId != user.Id)
|
|
||||||
{
|
|
||||||
return BadRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!loggedUser.Flags.HasFlag(VoidUserFlags.EmailVerified)) return Forbid();
|
||||||
|
|
||||||
await _store.Update(user);
|
await _store.Update(user);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("{id}/files")]
|
[Route("files")]
|
||||||
public async Task<RenderedResults<PublicVoidFile>?> ListUserFiles([FromRoute] string id, [FromBody] PagedRequest request)
|
public async Task<IActionResult> ListUserFiles([FromRoute] string id,
|
||||||
|
[FromBody] PagedRequest request)
|
||||||
|
{
|
||||||
|
var loggedUser = HttpContext.GetUserId();
|
||||||
|
var isAdmin = HttpContext.IsRole(Roles.Admin);
|
||||||
|
|
||||||
|
var user = await GetRequestedUser(id);
|
||||||
|
if (user == default) return NotFound();
|
||||||
|
|
||||||
|
// not logged in user files, check public flag
|
||||||
|
var canViewUploads = loggedUser == user.Id || isAdmin;
|
||||||
|
if (!canViewUploads &&
|
||||||
|
!user.Flags.HasFlag(VoidUserFlags.PublicUploads)) return Forbid();
|
||||||
|
|
||||||
|
var results = await _userUploads.ListFiles(id.FromBase58Guid(), request);
|
||||||
|
return Json(await results.GetResults());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Route("verify")]
|
||||||
|
public async Task<IActionResult> SendVerificationCode([FromRoute] string id)
|
||||||
|
{
|
||||||
|
var user = await GetAuthorizedUser(id);
|
||||||
|
if (user == default) return Unauthorized();
|
||||||
|
|
||||||
|
var isEmailVerified = (user?.Flags.HasFlag(VoidUserFlags.EmailVerified) ?? false);
|
||||||
|
if (isEmailVerified) return UnprocessableEntity();
|
||||||
|
|
||||||
|
await _emailVerification.SendNewCode(user!);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Route("verify")]
|
||||||
|
public async Task<IActionResult> VerifyCode([FromRoute] string id, [FromBody] string code)
|
||||||
|
{
|
||||||
|
var user = await GetAuthorizedUser(id);
|
||||||
|
if (user == default) return Unauthorized();
|
||||||
|
|
||||||
|
var token = code.FromBase58Guid();
|
||||||
|
if (!await _emailVerification.VerifyCode(user, token)) return BadRequest();
|
||||||
|
|
||||||
|
user.Flags |= VoidUserFlags.EmailVerified;
|
||||||
|
await _store.Set(user);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InternalVoidUser?> GetAuthorizedUser(string id)
|
||||||
{
|
{
|
||||||
var loggedUser = HttpContext.GetUserId();
|
var loggedUser = HttpContext.GetUserId();
|
||||||
var gid = id.FromBase58Guid();
|
var gid = id.FromBase58Guid();
|
||||||
var user = await _store.Get<PublicVoidUser>(gid);
|
var user = await _store.Get<InternalVoidUser>(gid);
|
||||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicUploads) ?? false) && loggedUser != gid) return default;
|
return user?.Id != loggedUser ? default : user;
|
||||||
|
|
||||||
var results = await _userUploads.ListFiles(id.FromBase58Guid(), request);
|
|
||||||
return await results.GetResults();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private async Task<InternalVoidUser?> GetRequestedUser(string id)
|
||||||
|
{
|
||||||
|
var gid = id.FromBase58Guid();
|
||||||
|
return await _store.Get<InternalVoidUser>(gid);
|
||||||
|
}
|
||||||
|
}
|
8
VoidCat/Model/EmailVerificationCode.cs
Normal file
8
VoidCat/Model/EmailVerificationCode.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace VoidCat.Model;
|
||||||
|
|
||||||
|
public class EmailVerificationCode
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public Guid UserId { get; init; }
|
||||||
|
public DateTimeOffset Expires { get; init; }
|
||||||
|
}
|
@ -27,7 +27,18 @@ public static class Extensions
|
|||||||
var claimSub = context?.User?.Claims?.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value;
|
var claimSub = context?.User?.Claims?.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier)?.Value;
|
||||||
return Guid.TryParse(claimSub, out var g) ? g : null;
|
return Guid.TryParse(claimSub, out var g) ? g : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<string>? GetUserRoles(this HttpContext context)
|
||||||
|
{
|
||||||
|
return context?.User?.Claims?.Where(a => a.Type == ClaimTypes.Role)
|
||||||
|
?.Select(a => a?.Value!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRole(this HttpContext context, string role)
|
||||||
|
{
|
||||||
|
return GetUserRoles(context)?.Contains(role) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public static Guid FromBase58Guid(this string base58)
|
public static Guid FromBase58Guid(this string base58)
|
||||||
{
|
{
|
||||||
var enc = new NBitcoin.DataEncoders.Base58Encoder();
|
var enc = new NBitcoin.DataEncoders.Base58Encoder();
|
||||||
|
@ -76,5 +76,6 @@ public sealed class PublicVoidUser : VoidUser
|
|||||||
public enum VoidUserFlags
|
public enum VoidUserFlags
|
||||||
{
|
{
|
||||||
PublicProfile = 1,
|
PublicProfile = 1,
|
||||||
PublicUploads = 2
|
PublicUploads = 2,
|
||||||
|
EmailVerified = 4
|
||||||
}
|
}
|
||||||
|
36
VoidCat/Pages/EmailCode.cshtml
Normal file
36
VoidCat/Pages/EmailCode.cshtml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
@using VoidCat.Model
|
||||||
|
@model VoidCat.Model.EmailVerificationCode
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>void.cat - Email Verification Code</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Source Code Pro', monospace;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
width: 720px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 24px;
|
||||||
|
background-color: #eee;
|
||||||
|
width: fit-content;
|
||||||
|
color: black;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>void.cat</h1>
|
||||||
|
<p>Your verification code is below please copy this to complete verification</p>
|
||||||
|
<pre>@(Model?.Id.ToBase58() ?? "?????????????")</pre>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -5,6 +5,7 @@ using Newtonsoft.Json;
|
|||||||
using Prometheus;
|
using Prometheus;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using VoidCat.Model;
|
using VoidCat.Model;
|
||||||
|
using VoidCat.Services;
|
||||||
using VoidCat.Services.Abstractions;
|
using VoidCat.Services.Abstractions;
|
||||||
using VoidCat.Services.Files;
|
using VoidCat.Services.Files;
|
||||||
using VoidCat.Services.InMemory;
|
using VoidCat.Services.InMemory;
|
||||||
@ -53,9 +54,10 @@ services.AddCors(opt =>
|
|||||||
.WithOrigins(voidSettings.CorsOrigins.Select(a => a.OriginalString).ToArray());
|
.WithOrigins(voidSettings.CorsOrigins.Select(a => a.OriginalString).ToArray());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
services.AddRazorPages();
|
||||||
services.AddRouting();
|
services.AddRouting();
|
||||||
services.AddControllers().AddNewtonsoftJson((opt) => { ConfigJsonSettings(opt.SerializerSettings); });
|
services.AddControllers()
|
||||||
|
.AddNewtonsoftJson((opt) => { ConfigJsonSettings(opt.SerializerSettings); });
|
||||||
|
|
||||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
@ -75,6 +77,7 @@ services.AddAuthorization((opt) => { opt.AddPolicy(Policies.RequireAdmin, (auth)
|
|||||||
|
|
||||||
// void.cat services
|
// void.cat services
|
||||||
//
|
//
|
||||||
|
services.AddTransient<RazorPartialToStringRenderer>();
|
||||||
services.AddVoidMigrations();
|
services.AddVoidMigrations();
|
||||||
|
|
||||||
// file storage
|
// file storage
|
||||||
@ -90,6 +93,7 @@ services.AddVoidPaywall();
|
|||||||
// users
|
// users
|
||||||
services.AddTransient<IUserStore, UserStore>();
|
services.AddTransient<IUserStore, UserStore>();
|
||||||
services.AddTransient<IUserManager, UserManager>();
|
services.AddTransient<IUserManager, UserManager>();
|
||||||
|
services.AddTransient<IEmailVerification, EmailVerification>();
|
||||||
|
|
||||||
if (useRedis)
|
if (useRedis)
|
||||||
{
|
{
|
||||||
@ -129,6 +133,7 @@ app.UseEndpoints(ep =>
|
|||||||
{
|
{
|
||||||
ep.MapControllers();
|
ep.MapControllers();
|
||||||
ep.MapMetrics();
|
ep.MapMetrics();
|
||||||
|
ep.MapRazorPages();
|
||||||
#if HostSPA
|
#if HostSPA
|
||||||
ep.MapFallbackToFile("index.html");
|
ep.MapFallbackToFile("index.html");
|
||||||
#endif
|
#endif
|
||||||
|
@ -7,4 +7,6 @@ public interface ICache
|
|||||||
|
|
||||||
ValueTask<string[]> GetList(string key);
|
ValueTask<string[]> GetList(string key);
|
||||||
ValueTask AddToList(string key, string value);
|
ValueTask AddToList(string key, string value);
|
||||||
|
|
||||||
|
ValueTask Delete(string key);
|
||||||
}
|
}
|
||||||
|
10
VoidCat/Services/Abstractions/IEmailVerification.cs
Normal file
10
VoidCat/Services/Abstractions/IEmailVerification.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using VoidCat.Model;
|
||||||
|
|
||||||
|
namespace VoidCat.Services.Abstractions;
|
||||||
|
|
||||||
|
public interface IEmailVerification
|
||||||
|
{
|
||||||
|
ValueTask<EmailVerificationCode> SendNewCode(PrivateVoidUser user);
|
||||||
|
|
||||||
|
ValueTask<bool> VerifyCode(PrivateVoidUser user, Guid code);
|
||||||
|
}
|
@ -4,6 +4,6 @@ namespace VoidCat.Services.Abstractions;
|
|||||||
|
|
||||||
public interface IUserManager
|
public interface IUserManager
|
||||||
{
|
{
|
||||||
ValueTask<VoidUser> Login(string username, string password);
|
ValueTask<InternalVoidUser> Login(string username, string password);
|
||||||
ValueTask<VoidUser> Register(string username, string password);
|
ValueTask<InternalVoidUser> Register(string username, string password);
|
||||||
}
|
}
|
||||||
|
@ -43,4 +43,10 @@ public class InMemoryCache : ICache
|
|||||||
_cache.Set(key, list.ToArray());
|
_cache.Set(key, list.ToArray());
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ValueTask Delete(string key)
|
||||||
|
{
|
||||||
|
_cache.Remove(key);
|
||||||
|
return ValueTask.CompletedTask;;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,4 +34,9 @@ public class RedisCache : ICache
|
|||||||
{
|
{
|
||||||
await _db.SetAddAsync(key, value);
|
await _db.SetAddAsync(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask Delete(string key)
|
||||||
|
{
|
||||||
|
await _db.KeyDeleteAsync(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
79
VoidCat/Services/Users/EmailVerification.cs
Normal file
79
VoidCat/Services/Users/EmailVerification.cs
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using VoidCat.Model;
|
||||||
|
using VoidCat.Services.Abstractions;
|
||||||
|
|
||||||
|
namespace VoidCat.Services.Users;
|
||||||
|
|
||||||
|
public class EmailVerification : IEmailVerification
|
||||||
|
{
|
||||||
|
private readonly ICache _cache;
|
||||||
|
private readonly VoidSettings _settings;
|
||||||
|
private readonly ILogger<EmailVerification> _logger;
|
||||||
|
private readonly RazorPartialToStringRenderer _renderer;
|
||||||
|
|
||||||
|
public EmailVerification(ICache cache, ILogger<EmailVerification> logger, VoidSettings settings, RazorPartialToStringRenderer renderer)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings;
|
||||||
|
_renderer = renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<EmailVerificationCode> SendNewCode(PrivateVoidUser user)
|
||||||
|
{
|
||||||
|
const int codeExpire = 1;
|
||||||
|
var code = new EmailVerificationCode()
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddHours(codeExpire)
|
||||||
|
};
|
||||||
|
await _cache.Set(MapToken(code.Id), code, TimeSpan.FromHours(codeExpire));
|
||||||
|
_logger.LogInformation("Saved email verification token for User={Id} Token={Token}", user.Id, code.Id);
|
||||||
|
|
||||||
|
// send email
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var conf = _settings.Smtp;
|
||||||
|
using var sc = new SmtpClient();
|
||||||
|
sc.Host = conf?.Server?.Host!;
|
||||||
|
sc.Port = conf?.Server?.Port ?? 25;
|
||||||
|
sc.EnableSsl = conf?.Server?.Scheme == "tls";
|
||||||
|
sc.Credentials = new NetworkCredential(conf?.Username, conf?.Password);
|
||||||
|
|
||||||
|
var msgContent = await _renderer.RenderPartialToStringAsync("~/Pages/EmailCode.cshtml", code);
|
||||||
|
var msg = new MailMessage();
|
||||||
|
msg.From = new MailAddress(conf?.Username ?? "no-reply@void.cat");
|
||||||
|
msg.To.Add(user.Email);
|
||||||
|
msg.Subject = "Email verification code";
|
||||||
|
msg.IsBodyHtml = true;
|
||||||
|
msg.Body = msgContent;
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
cts.CancelAfter(TimeSpan.FromMinutes(1));
|
||||||
|
await sc.SendMailAsync(msg, cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to send email verification code {Error}", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<bool> VerifyCode(PrivateVoidUser user, Guid code)
|
||||||
|
{
|
||||||
|
var token = await _cache.Get<EmailVerificationCode>(MapToken(code));
|
||||||
|
if (token == default) return false;
|
||||||
|
|
||||||
|
var isValid = user.Id == token.UserId && token.Expires > DateTimeOffset.UtcNow;
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
await _cache.Delete(MapToken(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapToken(Guid id) => $"email-code:{id}";
|
||||||
|
}
|
@ -6,14 +6,16 @@ namespace VoidCat.Services.Users;
|
|||||||
public class UserManager : IUserManager
|
public class UserManager : IUserManager
|
||||||
{
|
{
|
||||||
private readonly IUserStore _store;
|
private readonly IUserStore _store;
|
||||||
|
private readonly IEmailVerification _emailVerification;
|
||||||
private static bool _checkFirstRegister;
|
private static bool _checkFirstRegister;
|
||||||
|
|
||||||
public UserManager(IUserStore store)
|
public UserManager(IUserStore store, IEmailVerification emailVerification)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
|
_emailVerification = emailVerification;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<VoidUser> Login(string email, string password)
|
public async ValueTask<InternalVoidUser> Login(string email, string password)
|
||||||
{
|
{
|
||||||
var userId = await _store.LookupUser(email);
|
var userId = await _store.LookupUser(email);
|
||||||
if (!userId.HasValue) throw new InvalidOperationException("User does not exist");
|
if (!userId.HasValue) throw new InvalidOperationException("User does not exist");
|
||||||
@ -27,7 +29,7 @@ public class UserManager : IUserManager
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<VoidUser> Register(string email, string password)
|
public async ValueTask<InternalVoidUser> Register(string email, string password)
|
||||||
{
|
{
|
||||||
var existingUser = await _store.LookupUser(email);
|
var existingUser = await _store.LookupUser(email);
|
||||||
if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists");
|
if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists");
|
||||||
@ -50,6 +52,7 @@ public class UserManager : IUserManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _store.Set(newUser);
|
await _store.Set(newUser);
|
||||||
|
await _emailVerification.SendNewCode(newUser);
|
||||||
return newUser;
|
return newUser;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
82
VoidCat/Services/ViewRenderer.cs
Normal file
82
VoidCat/Services/ViewRenderer.cs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
|
|
||||||
|
namespace VoidCat.Services;
|
||||||
|
|
||||||
|
public class RazorPartialToStringRenderer
|
||||||
|
{
|
||||||
|
private readonly IRazorViewEngine _viewEngine;
|
||||||
|
private readonly ITempDataProvider _tempDataProvider;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
public RazorPartialToStringRenderer(
|
||||||
|
IRazorViewEngine viewEngine,
|
||||||
|
ITempDataProvider tempDataProvider,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_viewEngine = viewEngine;
|
||||||
|
_tempDataProvider = tempDataProvider;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> RenderPartialToStringAsync<TModel>(string partialName, TModel model)
|
||||||
|
{
|
||||||
|
var actionContext = GetActionContext();
|
||||||
|
var partial = FindView(actionContext, partialName);
|
||||||
|
await using var output = new StringWriter();
|
||||||
|
var viewContext = new ViewContext(
|
||||||
|
actionContext,
|
||||||
|
partial,
|
||||||
|
new ViewDataDictionary<TModel>(
|
||||||
|
metadataProvider: new EmptyModelMetadataProvider(),
|
||||||
|
modelState: new ModelStateDictionary())
|
||||||
|
{
|
||||||
|
Model = model
|
||||||
|
},
|
||||||
|
new TempDataDictionary(
|
||||||
|
actionContext.HttpContext,
|
||||||
|
_tempDataProvider),
|
||||||
|
output,
|
||||||
|
new HtmlHelperOptions()
|
||||||
|
);
|
||||||
|
await partial.RenderAsync(viewContext);
|
||||||
|
return output.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IView FindView(ActionContext actionContext, string partialName)
|
||||||
|
{
|
||||||
|
var getPartialResult = _viewEngine.GetView(null, partialName, false);
|
||||||
|
if (getPartialResult.Success)
|
||||||
|
{
|
||||||
|
return getPartialResult.View;
|
||||||
|
}
|
||||||
|
|
||||||
|
var findPartialResult = _viewEngine.FindView(actionContext, partialName, false);
|
||||||
|
if (findPartialResult.Success)
|
||||||
|
{
|
||||||
|
return findPartialResult.View;
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchedLocations = getPartialResult.SearchedLocations.Concat(findPartialResult.SearchedLocations);
|
||||||
|
var errorMessage = string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
new[] {$"Unable to find partial '{partialName}'. The following locations were searched:"}.Concat(
|
||||||
|
searchedLocations));
|
||||||
|
;
|
||||||
|
throw new InvalidOperationException(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionContext GetActionContext()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
RequestServices = _serviceProvider
|
||||||
|
};
|
||||||
|
return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||||
|
}
|
||||||
|
}
|
@ -35,6 +35,9 @@
|
|||||||
<None Remove="$(SpaRoot)**" />
|
<None Remove="$(SpaRoot)**" />
|
||||||
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
|
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="Pages\EmailCode.cshtml.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
|
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
|
||||||
<!-- Ensure Node.js is installed -->
|
<!-- Ensure Node.js is installed -->
|
||||||
|
@ -39,7 +39,9 @@ export function useApi() {
|
|||||||
register: (username, password) => getJson("POST", `/auth/register`, {username, password}),
|
register: (username, password) => getJson("POST", `/auth/register`, {username, password}),
|
||||||
getUser: (id) => getJson("GET", `/user/${id}`, undefined, auth),
|
getUser: (id) => getJson("GET", `/user/${id}`, undefined, auth),
|
||||||
updateUser: (u) => getJson("POST", `/user/${u.id}`, u, auth),
|
updateUser: (u) => getJson("POST", `/user/${u.id}`, u, auth),
|
||||||
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth)
|
listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth),
|
||||||
|
submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth),
|
||||||
|
sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -69,5 +69,6 @@ export const PageSortOrder = {
|
|||||||
|
|
||||||
export const UserFlags = {
|
export const UserFlags = {
|
||||||
PublicProfile: 1,
|
PublicProfile: 1,
|
||||||
PublicUploads: 2
|
PublicUploads: 2,
|
||||||
|
EmailVerified: 4
|
||||||
}
|
}
|
@ -53,6 +53,11 @@ export function Dropzone(props) {
|
|||||||
document.addEventListener("paste", dropFiles);
|
document.addEventListener("paste", dropFiles);
|
||||||
document.addEventListener("drop", dropFiles);
|
document.addEventListener("drop", dropFiles);
|
||||||
document.addEventListener("dragover", dropFiles);
|
document.addEventListener("dragover", dropFiles);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("paste", dropFiles);
|
||||||
|
document.removeEventListener("drop", dropFiles);
|
||||||
|
document.removeEventListener("dragover", dropFiles);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return files.length === 0 ? renderDrop() : renderUploads();
|
return files.length === 0 ? renderDrop() : renderUploads();
|
||||||
|
@ -15,11 +15,14 @@ export function FilePaywall(props) {
|
|||||||
const [order, setOrder] = useState();
|
const [order, setOrder] = useState();
|
||||||
|
|
||||||
async function fetchOrder(e) {
|
async function fetchOrder(e) {
|
||||||
e.target.disabled = true;
|
if(e.target.classList.contains("disabled")) return;
|
||||||
|
e.target.classList.add("disabled");
|
||||||
|
|
||||||
let req = await Api.createOrder(file.id);
|
let req = await Api.createOrder(file.id);
|
||||||
if (req.ok) {
|
if (req.ok && req.status === 200) {
|
||||||
setOrder(await req.json());
|
setOrder(await req.json());
|
||||||
}
|
}
|
||||||
|
e.target.classList.remove("disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
@ -3,6 +3,7 @@ import {useDispatch} from "react-redux";
|
|||||||
import {setAuth} from "./LoginState";
|
import {setAuth} from "./LoginState";
|
||||||
import {useApi} from "./Api";
|
import {useApi} from "./Api";
|
||||||
import "./Login.css";
|
import "./Login.css";
|
||||||
|
import {btnDisable, btnEnable} from "./Util";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const {Api} = useApi();
|
const {Api} = useApi();
|
||||||
@ -12,7 +13,7 @@ export function Login() {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
async function login(e, fnLogin) {
|
async function login(e, fnLogin) {
|
||||||
e.target.disabled = true;
|
if(!btnDisable(e.target)) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
let req = await fnLogin(username, password);
|
let req = await fnLogin(username, password);
|
||||||
@ -25,7 +26,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.target.disabled = false;
|
btnEnable(e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -33,12 +34,12 @@ export function Login() {
|
|||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Username:</dt>
|
<dt>Username:</dt>
|
||||||
<dd><input onChange={(e) => setUsername(e.target.value)} placeholder="user@example.com"/></dd>
|
<dd><input type="text" onChange={(e) => setUsername(e.target.value)} placeholder="user@example.com"/></dd>
|
||||||
<dt>Password:</dt>
|
<dt>Password:</dt>
|
||||||
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
|
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<button onClick={(e) => login(e, Api.login)}>Login</button>
|
<div className="btn" onClick={(e) => login(e, Api.login)}>Login</div>
|
||||||
<button onClick={(e) => login(e, Api.register)}>Register</button>
|
<div className="btn" onClick={(e) => login(e, Api.register)}>Register</div>
|
||||||
{error ? <div className="error-msg">{error}</div> : null}
|
{error ? <div className="error-msg">{error}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import FeatherIcon from "feather-icons-react";
|
import FeatherIcon from "feather-icons-react";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
|
import {btnDisable, btnEnable} from "./Util";
|
||||||
|
|
||||||
export function NoPaywallConfig(props) {
|
export function NoPaywallConfig(props) {
|
||||||
const [saveStatus, setSaveStatus] = useState();
|
const [saveStatus, setSaveStatus] = useState();
|
||||||
@ -7,7 +8,8 @@ export function NoPaywallConfig(props) {
|
|||||||
const onSaveConfig = props.onSaveConfig;
|
const onSaveConfig = props.onSaveConfig;
|
||||||
|
|
||||||
async function saveConfig(e) {
|
async function saveConfig(e) {
|
||||||
e.target.disabled = true;
|
if(!btnDisable(e.target)) return;
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
editSecret: privateFile.metadata.editSecret
|
editSecret: privateFile.metadata.editSecret
|
||||||
};
|
};
|
||||||
@ -15,12 +17,12 @@ export function NoPaywallConfig(props) {
|
|||||||
if (typeof onSaveConfig === "function") {
|
if (typeof onSaveConfig === "function") {
|
||||||
setSaveStatus(await onSaveConfig(cfg));
|
setSaveStatus(await onSaveConfig(cfg));
|
||||||
}
|
}
|
||||||
e.target.disabled = false;
|
btnEnable(e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={saveConfig}>Save</button>
|
<div className="btn" onClick={saveConfig}>Save</div>
|
||||||
{saveStatus ? <FeatherIcon icon={saveStatus === true ? "check-circle" : "alert-circle"}/> : null}
|
{saveStatus ? <FeatherIcon icon={saveStatus === true ? "check-circle" : "alert-circle"}/> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -6,7 +6,7 @@ import "./Profile.css";
|
|||||||
import {useDispatch, useSelector} from "react-redux";
|
import {useDispatch, useSelector} from "react-redux";
|
||||||
import {logout, setProfile as setGlobalProfile} from "./LoginState";
|
import {logout, setProfile as setGlobalProfile} from "./LoginState";
|
||||||
import {DigestAlgo} from "./FileUpload";
|
import {DigestAlgo} from "./FileUpload";
|
||||||
import {buf2hex, hasFlag} from "./Util";
|
import {btnDisable, btnEnable, buf2hex, hasFlag} from "./Util";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import FeatherIcon from "feather-icons-react";
|
import FeatherIcon from "feather-icons-react";
|
||||||
import {FileList} from "./FileList";
|
import {FileList} from "./FileList";
|
||||||
@ -15,9 +15,16 @@ export function Profile() {
|
|||||||
const [profile, setProfile] = useState();
|
const [profile, setProfile] = useState();
|
||||||
const [noProfile, setNoProfile] = useState(false);
|
const [noProfile, setNoProfile] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [emailCode, setEmailCode] = useState("");
|
||||||
|
const [emailCodeError, setEmailCodeError] = useState("");
|
||||||
|
const [newCodeSent, setNewCodeSent] = useState(false);
|
||||||
const auth = useSelector(state => state.login.jwt);
|
const auth = useSelector(state => state.login.jwt);
|
||||||
const localProfile = useSelector(state => state.login.profile);
|
const localProfile = useSelector(state => state.login.profile);
|
||||||
|
|
||||||
const canEdit = localProfile?.id === profile?.id;
|
const canEdit = localProfile?.id === profile?.id;
|
||||||
|
const needsEmailVerify = canEdit && (profile?.flags & UserFlags.EmailVerified) !== UserFlags.EmailVerified;
|
||||||
|
const cantEditProfile = canEdit && !needsEmailVerify;
|
||||||
|
|
||||||
const {Api} = useApi();
|
const {Api} = useApi();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@ -87,7 +94,9 @@ export function Profile() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser() {
|
async function saveUser(e) {
|
||||||
|
if(!btnDisable(e.target)) return;
|
||||||
|
|
||||||
let r = await Api.updateUser({
|
let r = await Api.updateUser({
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
avatar: profile.avatar,
|
avatar: profile.avatar,
|
||||||
@ -99,6 +108,76 @@ export function Profile() {
|
|||||||
dispatch(setGlobalProfile(profile));
|
dispatch(setGlobalProfile(profile));
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
}
|
}
|
||||||
|
btnEnable(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCode(e) {
|
||||||
|
if(!btnDisable(e.target)) return;
|
||||||
|
|
||||||
|
let r = await Api.submitVerifyCode(profile.id, emailCode);
|
||||||
|
if (r.ok) {
|
||||||
|
await loadProfile();
|
||||||
|
} else {
|
||||||
|
setEmailCodeError("Invalid or expired code.");
|
||||||
|
}
|
||||||
|
btnEnable(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNewCode() {
|
||||||
|
setNewCodeSent(true);
|
||||||
|
let r = await Api.sendNewCode(profile.id);
|
||||||
|
if (!r.ok) {
|
||||||
|
setNewCodeSent(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmailVerify() {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<h2>Please enter email verification code</h2>
|
||||||
|
<small>Your account will automatically be deleted in 7 days if you do not verify your email
|
||||||
|
address.</small>
|
||||||
|
<br/>
|
||||||
|
<input type="text" placeholder="Verification code" value={emailCode}
|
||||||
|
onChange={(e) => setEmailCode(e.target.value)}/>
|
||||||
|
<div className="btn" onClick={submitCode}>Submit</div>
|
||||||
|
<div className="btn" onClick={() => dispatch(logout())}>Logout</div>
|
||||||
|
<br/>
|
||||||
|
{emailCodeError ? <b>{emailCodeError}</b> : null}
|
||||||
|
{emailCodeError && !newCodeSent ? <a onClick={sendNewCode}>Send verfication email</a> : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfileEdit() {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<dl>
|
||||||
|
<dt>Public Profile:</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="checkbox" checked={hasFlag(profile.flags, UserFlags.PublicProfile)}
|
||||||
|
onChange={(e) => toggleFlag(UserFlags.PublicProfile)}/>
|
||||||
|
</dd>
|
||||||
|
<dt>Public Uploads:</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="checkbox" checked={hasFlag(profile.flags, UserFlags.PublicUploads)}
|
||||||
|
onChange={(e) => toggleFlag(UserFlags.PublicUploads)}/>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
</dl>
|
||||||
|
<div className="flex flex-center">
|
||||||
|
<div>
|
||||||
|
<div className="btn" onClick={saveUser}>Save</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{saved ? <FeatherIcon icon="check-circle"/> : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="btn" onClick={() => dispatch(logout())}>Logout</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -124,7 +203,7 @@ export function Profile() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div className="name">
|
<div className="name">
|
||||||
{canEdit ?
|
{cantEditProfile ?
|
||||||
<input value={profile.displayName}
|
<input value={profile.displayName}
|
||||||
onChange={(e) => editUsername(e.target.value)}/>
|
onChange={(e) => editUsername(e.target.value)}/>
|
||||||
: profile.displayName}
|
: profile.displayName}
|
||||||
@ -132,7 +211,7 @@ export function Profile() {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flx-1">
|
<div className="flx-1">
|
||||||
<div className="avatar" style={avatarStyles}>
|
<div className="avatar" style={avatarStyles}>
|
||||||
{canEdit ? <div className="edit-avatar" onClick={() => changeAvatar()}>
|
{cantEditProfile ? <div className="edit-avatar" onClick={() => changeAvatar()}>
|
||||||
<h3>Edit</h3>
|
<h3>Edit</h3>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -146,33 +225,8 @@ export function Profile() {
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canEdit ?
|
{cantEditProfile ? renderProfileEdit() : null}
|
||||||
<Fragment>
|
{needsEmailVerify ? renderEmailVerify() : null}
|
||||||
<dl>
|
|
||||||
<dt>Public Profile:</dt>
|
|
||||||
<dd>
|
|
||||||
<input type="checkbox" checked={hasFlag(profile.flags, UserFlags.PublicProfile)}
|
|
||||||
onChange={(e) => toggleFlag(UserFlags.PublicProfile)}/>
|
|
||||||
</dd>
|
|
||||||
<dt>Public Uploads:</dt>
|
|
||||||
<dd>
|
|
||||||
<input type="checkbox" checked={hasFlag(profile.flags, UserFlags.PublicUploads)}
|
|
||||||
onChange={(e) => toggleFlag(UserFlags.PublicUploads)}/>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
</dl>
|
|
||||||
<div className="flex flex-center">
|
|
||||||
<div>
|
|
||||||
<div className="btn" onClick={saveUser}>Save</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{saved ? <FeatherIcon icon="check-circle"/> : null}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="btn" onClick={() => dispatch(logout())}>Logout</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Fragment> : null}
|
|
||||||
<h1>Uploads</h1>
|
<h1>Uploads</h1>
|
||||||
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
|
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import FeatherIcon from "feather-icons-react";
|
import FeatherIcon from "feather-icons-react";
|
||||||
import {PaywallCurrencies} from "./Const";
|
import {PaywallCurrencies} from "./Const";
|
||||||
|
import {btnDisable, btnEnable} from "./Util";
|
||||||
|
|
||||||
export function StrikePaywallConfig(props) {
|
export function StrikePaywallConfig(props) {
|
||||||
const file = props.file;
|
const file = props.file;
|
||||||
@ -15,7 +16,8 @@ export function StrikePaywallConfig(props) {
|
|||||||
const [saveStatus, setSaveStatus] = useState();
|
const [saveStatus, setSaveStatus] = useState();
|
||||||
|
|
||||||
async function saveStrikeConfig(e) {
|
async function saveStrikeConfig(e) {
|
||||||
e.target.disabled = true;
|
if(!btnDisable(e.target)) return;
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
editSecret,
|
editSecret,
|
||||||
strike: {
|
strike: {
|
||||||
@ -35,7 +37,7 @@ export function StrikePaywallConfig(props) {
|
|||||||
setSaveStatus(false);
|
setSaveStatus(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.target.disabled = false;
|
btnEnable(e.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -55,7 +57,7 @@ export function StrikePaywallConfig(props) {
|
|||||||
<dt>Price:</dt>
|
<dt>Price:</dt>
|
||||||
<dd><input type="number" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))}/></dd>
|
<dd><input type="number" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))}/></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<button onClick={saveStrikeConfig}>Save</button>
|
<div className="btn" onClick={saveStrikeConfig}>Save</div>
|
||||||
{saveStatus ? <FeatherIcon icon="check-circle"/> : null}
|
{saveStatus ? <FeatherIcon icon="check-circle"/> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -78,5 +78,15 @@ export function FormatCurrency(value, currency) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasFlag(value, flag) {
|
export function hasFlag(value, flag) {
|
||||||
return (value & flag) !== 0;
|
return (value & flag) === flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function btnDisable(btn){
|
||||||
|
if(btn.classList.contains("disabled")) return false;
|
||||||
|
btn.classList.add("disabled");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function btnEnable(btn){
|
||||||
|
btn.classList.remove("disabled");
|
||||||
}
|
}
|
@ -1,21 +1,21 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap');
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Source Code Pro', monospace;
|
font-family: 'Source Code Pro', monospace;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@ -32,6 +32,10 @@ a:hover {
|
|||||||
margin: 5px;
|
margin: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.disabled {
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@ -50,4 +54,13 @@ a:hover {
|
|||||||
|
|
||||||
.flex-center {
|
.flex-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"], input[type="number"], input[type="password"], select {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1.1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 5px;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user