mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-03-18 05:43:00 +01:00
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;
|
||||
|
||||
[Route("user")]
|
||||
[Route("user/{id}")]
|
||||
public class UserController : Controller
|
||||
{
|
||||
private readonly IUserStore _store;
|
||||
private readonly IUserUploadsStore _userUploads;
|
||||
private readonly IEmailVerification _emailVerification;
|
||||
|
||||
public UserController(IUserStore store, IUserUploadsStore userUploads)
|
||||
public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification)
|
||||
{
|
||||
_store = store;
|
||||
_userUploads = userUploads;
|
||||
_emailVerification = emailVerification;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Route("{id}")]
|
||||
public async Task<VoidUser?> GetUser([FromRoute] string id)
|
||||
[Route("")]
|
||||
public async Task<IActionResult> GetUser([FromRoute] string id)
|
||||
{
|
||||
var loggedUser = HttpContext.GetUserId();
|
||||
var requestedId = id.FromBase58Guid();
|
||||
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());
|
||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return default;
|
||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicProfile) ?? false)) return NotFound();
|
||||
|
||||
return user;
|
||||
return Json(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{id}")]
|
||||
[Route("")]
|
||||
public async Task<IActionResult> UpdateUser([FromRoute] string id, [FromBody] PublicVoidUser user)
|
||||
{
|
||||
var loggedUser = HttpContext.GetUserId();
|
||||
var requestedId = id.FromBase58Guid();
|
||||
if (requestedId != loggedUser)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// check requested user is same as user obj
|
||||
if (requestedId != user.Id)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
var loggedUser = await GetAuthorizedUser(id);
|
||||
if (loggedUser == default) return Unauthorized();
|
||||
|
||||
if (!loggedUser.Flags.HasFlag(VoidUserFlags.EmailVerified)) return Forbid();
|
||||
|
||||
await _store.Update(user);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{id}/files")]
|
||||
public async Task<RenderedResults<PublicVoidFile>?> ListUserFiles([FromRoute] string id, [FromBody] PagedRequest request)
|
||||
[Route("files")]
|
||||
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 gid = id.FromBase58Guid();
|
||||
var user = await _store.Get<PublicVoidUser>(gid);
|
||||
if (!(user?.Flags.HasFlag(VoidUserFlags.PublicUploads) ?? false) && loggedUser != gid) return default;
|
||||
|
||||
var results = await _userUploads.ListFiles(id.FromBase58Guid(), request);
|
||||
return await results.GetResults();
|
||||
var user = await _store.Get<InternalVoidUser>(gid);
|
||||
return user?.Id != loggedUser ? default : user;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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)
|
||||
{
|
||||
var enc = new NBitcoin.DataEncoders.Base58Encoder();
|
||||
|
@ -76,5 +76,6 @@ public sealed class PublicVoidUser : VoidUser
|
||||
public enum VoidUserFlags
|
||||
{
|
||||
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 StackExchange.Redis;
|
||||
using VoidCat.Model;
|
||||
using VoidCat.Services;
|
||||
using VoidCat.Services.Abstractions;
|
||||
using VoidCat.Services.Files;
|
||||
using VoidCat.Services.InMemory;
|
||||
@ -53,9 +54,10 @@ services.AddCors(opt =>
|
||||
.WithOrigins(voidSettings.CorsOrigins.Select(a => a.OriginalString).ToArray());
|
||||
});
|
||||
});
|
||||
|
||||
services.AddRazorPages();
|
||||
services.AddRouting();
|
||||
services.AddControllers().AddNewtonsoftJson((opt) => { ConfigJsonSettings(opt.SerializerSettings); });
|
||||
services.AddControllers()
|
||||
.AddNewtonsoftJson((opt) => { ConfigJsonSettings(opt.SerializerSettings); });
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
@ -75,6 +77,7 @@ services.AddAuthorization((opt) => { opt.AddPolicy(Policies.RequireAdmin, (auth)
|
||||
|
||||
// void.cat services
|
||||
//
|
||||
services.AddTransient<RazorPartialToStringRenderer>();
|
||||
services.AddVoidMigrations();
|
||||
|
||||
// file storage
|
||||
@ -90,6 +93,7 @@ services.AddVoidPaywall();
|
||||
// users
|
||||
services.AddTransient<IUserStore, UserStore>();
|
||||
services.AddTransient<IUserManager, UserManager>();
|
||||
services.AddTransient<IEmailVerification, EmailVerification>();
|
||||
|
||||
if (useRedis)
|
||||
{
|
||||
@ -129,6 +133,7 @@ app.UseEndpoints(ep =>
|
||||
{
|
||||
ep.MapControllers();
|
||||
ep.MapMetrics();
|
||||
ep.MapRazorPages();
|
||||
#if HostSPA
|
||||
ep.MapFallbackToFile("index.html");
|
||||
#endif
|
||||
|
@ -7,4 +7,6 @@ public interface ICache
|
||||
|
||||
ValueTask<string[]> GetList(string key);
|
||||
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
|
||||
{
|
||||
ValueTask<VoidUser> Login(string username, string password);
|
||||
ValueTask<VoidUser> Register(string username, string password);
|
||||
ValueTask<InternalVoidUser> Login(string username, string password);
|
||||
ValueTask<InternalVoidUser> Register(string username, string password);
|
||||
}
|
||||
|
@ -43,4 +43,10 @@ public class InMemoryCache : ICache
|
||||
_cache.Set(key, list.ToArray());
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
private readonly IUserStore _store;
|
||||
private readonly IEmailVerification _emailVerification;
|
||||
private static bool _checkFirstRegister;
|
||||
|
||||
public UserManager(IUserStore store)
|
||||
public UserManager(IUserStore store, IEmailVerification emailVerification)
|
||||
{
|
||||
_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);
|
||||
if (!userId.HasValue) throw new InvalidOperationException("User does not exist");
|
||||
@ -27,7 +29,7 @@ public class UserManager : IUserManager
|
||||
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);
|
||||
if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists");
|
||||
@ -50,6 +52,7 @@ public class UserManager : IUserManager
|
||||
}
|
||||
|
||||
await _store.Set(newUser);
|
||||
await _emailVerification.SendNewCode(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 Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="Pages\EmailCode.cshtml.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
|
||||
<!-- Ensure Node.js is installed -->
|
||||
|
@ -39,7 +39,9 @@ export function useApi() {
|
||||
register: (username, password) => getJson("POST", `/auth/register`, {username, password}),
|
||||
getUser: (id) => getJson("GET", `/user/${id}`, undefined, 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 = {
|
||||
PublicProfile: 1,
|
||||
PublicUploads: 2
|
||||
PublicUploads: 2,
|
||||
EmailVerified: 4
|
||||
}
|
@ -53,6 +53,11 @@ export function Dropzone(props) {
|
||||
document.addEventListener("paste", dropFiles);
|
||||
document.addEventListener("drop", dropFiles);
|
||||
document.addEventListener("dragover", dropFiles);
|
||||
return () => {
|
||||
document.removeEventListener("paste", dropFiles);
|
||||
document.removeEventListener("drop", dropFiles);
|
||||
document.removeEventListener("dragover", dropFiles);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return files.length === 0 ? renderDrop() : renderUploads();
|
||||
|
@ -15,11 +15,14 @@ export function FilePaywall(props) {
|
||||
const [order, setOrder] = useState();
|
||||
|
||||
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);
|
||||
if (req.ok) {
|
||||
if (req.ok && req.status === 200) {
|
||||
setOrder(await req.json());
|
||||
}
|
||||
e.target.classList.remove("disabled");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
|
@ -3,6 +3,7 @@ import {useDispatch} from "react-redux";
|
||||
import {setAuth} from "./LoginState";
|
||||
import {useApi} from "./Api";
|
||||
import "./Login.css";
|
||||
import {btnDisable, btnEnable} from "./Util";
|
||||
|
||||
export function Login() {
|
||||
const {Api} = useApi();
|
||||
@ -12,7 +13,7 @@ export function Login() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
async function login(e, fnLogin) {
|
||||
e.target.disabled = true;
|
||||
if(!btnDisable(e.target)) return;
|
||||
setError(null);
|
||||
|
||||
let req = await fnLogin(username, password);
|
||||
@ -25,7 +26,7 @@ export function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
e.target.disabled = false;
|
||||
btnEnable(e.target);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -33,12 +34,12 @@ export function Login() {
|
||||
<h2>Login</h2>
|
||||
<dl>
|
||||
<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>
|
||||
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
|
||||
</dl>
|
||||
<button onClick={(e) => login(e, Api.login)}>Login</button>
|
||||
<button onClick={(e) => login(e, Api.register)}>Register</button>
|
||||
<div className="btn" onClick={(e) => login(e, Api.login)}>Login</div>
|
||||
<div className="btn" onClick={(e) => login(e, Api.register)}>Register</div>
|
||||
{error ? <div className="error-msg">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import FeatherIcon from "feather-icons-react";
|
||||
import {useState} from "react";
|
||||
import {btnDisable, btnEnable} from "./Util";
|
||||
|
||||
export function NoPaywallConfig(props) {
|
||||
const [saveStatus, setSaveStatus] = useState();
|
||||
@ -7,7 +8,8 @@ export function NoPaywallConfig(props) {
|
||||
const onSaveConfig = props.onSaveConfig;
|
||||
|
||||
async function saveConfig(e) {
|
||||
e.target.disabled = true;
|
||||
if(!btnDisable(e.target)) return;
|
||||
|
||||
let cfg = {
|
||||
editSecret: privateFile.metadata.editSecret
|
||||
};
|
||||
@ -15,12 +17,12 @@ export function NoPaywallConfig(props) {
|
||||
if (typeof onSaveConfig === "function") {
|
||||
setSaveStatus(await onSaveConfig(cfg));
|
||||
}
|
||||
e.target.disabled = false;
|
||||
btnEnable(e.target);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={saveConfig}>Save</button>
|
||||
<div className="btn" onClick={saveConfig}>Save</div>
|
||||
{saveStatus ? <FeatherIcon icon={saveStatus === true ? "check-circle" : "alert-circle"}/> : null}
|
||||
</div>
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ import "./Profile.css";
|
||||
import {useDispatch, useSelector} from "react-redux";
|
||||
import {logout, setProfile as setGlobalProfile} from "./LoginState";
|
||||
import {DigestAlgo} from "./FileUpload";
|
||||
import {buf2hex, hasFlag} from "./Util";
|
||||
import {btnDisable, btnEnable, buf2hex, hasFlag} from "./Util";
|
||||
import moment from "moment";
|
||||
import FeatherIcon from "feather-icons-react";
|
||||
import {FileList} from "./FileList";
|
||||
@ -15,9 +15,16 @@ export function Profile() {
|
||||
const [profile, setProfile] = useState();
|
||||
const [noProfile, setNoProfile] = 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 localProfile = useSelector(state => state.login.profile);
|
||||
|
||||
const canEdit = localProfile?.id === profile?.id;
|
||||
const needsEmailVerify = canEdit && (profile?.flags & UserFlags.EmailVerified) !== UserFlags.EmailVerified;
|
||||
const cantEditProfile = canEdit && !needsEmailVerify;
|
||||
|
||||
const {Api} = useApi();
|
||||
const params = useParams();
|
||||
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({
|
||||
id: profile.id,
|
||||
avatar: profile.avatar,
|
||||
@ -99,6 +108,76 @@ export function Profile() {
|
||||
dispatch(setGlobalProfile(profile));
|
||||
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(() => {
|
||||
@ -124,7 +203,7 @@ export function Profile() {
|
||||
<div className="page">
|
||||
<div className="profile">
|
||||
<div className="name">
|
||||
{canEdit ?
|
||||
{cantEditProfile ?
|
||||
<input value={profile.displayName}
|
||||
onChange={(e) => editUsername(e.target.value)}/>
|
||||
: profile.displayName}
|
||||
@ -132,7 +211,7 @@ export function Profile() {
|
||||
<div className="flex">
|
||||
<div className="flx-1">
|
||||
<div className="avatar" style={avatarStyles}>
|
||||
{canEdit ? <div className="edit-avatar" onClick={() => changeAvatar()}>
|
||||
{cantEditProfile ? <div className="edit-avatar" onClick={() => changeAvatar()}>
|
||||
<h3>Edit</h3>
|
||||
</div> : null}
|
||||
</div>
|
||||
@ -146,33 +225,8 @@ export function Profile() {
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit ?
|
||||
<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> : null}
|
||||
{cantEditProfile ? renderProfileEdit() : null}
|
||||
{needsEmailVerify ? renderEmailVerify() : null}
|
||||
<h1>Uploads</h1>
|
||||
<FileList loadPage={(req) => Api.listUserFiles(profile.id, req)}/>
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {useState} from "react";
|
||||
import FeatherIcon from "feather-icons-react";
|
||||
import {PaywallCurrencies} from "./Const";
|
||||
import {btnDisable, btnEnable} from "./Util";
|
||||
|
||||
export function StrikePaywallConfig(props) {
|
||||
const file = props.file;
|
||||
@ -15,7 +16,8 @@ export function StrikePaywallConfig(props) {
|
||||
const [saveStatus, setSaveStatus] = useState();
|
||||
|
||||
async function saveStrikeConfig(e) {
|
||||
e.target.disabled = true;
|
||||
if(!btnDisable(e.target)) return;
|
||||
|
||||
let cfg = {
|
||||
editSecret,
|
||||
strike: {
|
||||
@ -35,7 +37,7 @@ export function StrikePaywallConfig(props) {
|
||||
setSaveStatus(false);
|
||||
}
|
||||
}
|
||||
e.target.disabled = false;
|
||||
btnEnable(e.target);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -55,7 +57,7 @@ export function StrikePaywallConfig(props) {
|
||||
<dt>Price:</dt>
|
||||
<dd><input type="number" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))}/></dd>
|
||||
</dl>
|
||||
<button onClick={saveStrikeConfig}>Save</button>
|
||||
<div className="btn" onClick={saveStrikeConfig}>Save</div>
|
||||
{saveStatus ? <FeatherIcon icon="check-circle"/> : null}
|
||||
</div>
|
||||
);
|
||||
|
@ -78,5 +78,15 @@ export function FormatCurrency(value, currency) {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: black;
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@ -32,6 +32,10 @@ a:hover {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@ -50,4 +54,13 @@ a:hover {
|
||||
|
||||
.flex-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…
x
Reference in New Issue
Block a user