From 72823ffedddcf759ee81ab7571b96e01bbec7256 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 2 Mar 2022 11:37:15 +0000 Subject: [PATCH] Add email verification Standardize button styles --- VoidCat/Controllers/UserController.cs | 100 ++++++++++----- VoidCat/Model/EmailVerificationCode.cs | 8 ++ VoidCat/Model/Extensions.cs | 11 ++ VoidCat/Model/VoidUser.cs | 3 +- VoidCat/Pages/EmailCode.cshtml | 36 ++++++ VoidCat/Program.cs | 9 +- VoidCat/Services/Abstractions/ICache.cs | 2 + .../Abstractions/IEmailVerification.cs | 10 ++ VoidCat/Services/Abstractions/IUserManager.cs | 4 +- VoidCat/Services/InMemory/InMemoryCache.cs | 6 + VoidCat/Services/Redis/RedisCache.cs | 5 + VoidCat/Services/Users/EmailVerification.cs | 79 ++++++++++++ VoidCat/Services/Users/UserManager.cs | 9 +- VoidCat/Services/ViewRenderer.cs | 82 +++++++++++++ VoidCat/VoidCat.csproj | 3 + VoidCat/spa/src/Api.js | 4 +- VoidCat/spa/src/Const.js | 3 +- VoidCat/spa/src/Dropzone.js | 5 + VoidCat/spa/src/FilePaywall.js | 7 +- VoidCat/spa/src/Login.js | 11 +- VoidCat/spa/src/NoPaywallConfig.js | 8 +- VoidCat/spa/src/Profile.js | 116 +++++++++++++----- VoidCat/spa/src/StrikePaywallConfig.js | 8 +- VoidCat/spa/src/Util.js | 12 +- VoidCat/spa/src/index.css | 31 +++-- 25 files changed, 480 insertions(+), 92 deletions(-) create mode 100644 VoidCat/Model/EmailVerificationCode.cs create mode 100644 VoidCat/Pages/EmailCode.cshtml create mode 100644 VoidCat/Services/Abstractions/IEmailVerification.cs create mode 100644 VoidCat/Services/Users/EmailVerification.cs create mode 100644 VoidCat/Services/ViewRenderer.cs diff --git a/VoidCat/Controllers/UserController.cs b/VoidCat/Controllers/UserController.cs index 5cc845f..b0d52e1 100644 --- a/VoidCat/Controllers/UserController.cs +++ b/VoidCat/Controllers/UserController.cs @@ -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 GetUser([FromRoute] string id) + [Route("")] + public async Task GetUser([FromRoute] string id) { var loggedUser = HttpContext.GetUserId(); var requestedId = id.FromBase58Guid(); if (loggedUser == requestedId) { - return await _store.Get(id.FromBase58Guid()); + return Json(await _store.Get(id.FromBase58Guid())); } var user = await _store.Get(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 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?> ListUserFiles([FromRoute] string id, [FromBody] PagedRequest request) + [Route("files")] + public async Task 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 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 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 GetAuthorizedUser(string id) { var loggedUser = HttpContext.GetUserId(); var gid = id.FromBase58Guid(); - var user = await _store.Get(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(gid); + return user?.Id != loggedUser ? default : user; } -} + + private async Task GetRequestedUser(string id) + { + var gid = id.FromBase58Guid(); + return await _store.Get(gid); + } +} \ No newline at end of file diff --git a/VoidCat/Model/EmailVerificationCode.cs b/VoidCat/Model/EmailVerificationCode.cs new file mode 100644 index 0000000..f8ee28c --- /dev/null +++ b/VoidCat/Model/EmailVerificationCode.cs @@ -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; } +} \ No newline at end of file diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index 8eda24b..61812a6 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -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? 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(); diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/VoidUser.cs index 4a15798..c21d9ab 100644 --- a/VoidCat/Model/VoidUser.cs +++ b/VoidCat/Model/VoidUser.cs @@ -76,5 +76,6 @@ public sealed class PublicVoidUser : VoidUser public enum VoidUserFlags { PublicProfile = 1, - PublicUploads = 2 + PublicUploads = 2, + EmailVerified = 4 } diff --git a/VoidCat/Pages/EmailCode.cshtml b/VoidCat/Pages/EmailCode.cshtml new file mode 100644 index 0000000..1dcccd9 --- /dev/null +++ b/VoidCat/Pages/EmailCode.cshtml @@ -0,0 +1,36 @@ +@using VoidCat.Model +@model VoidCat.Model.EmailVerificationCode + + + + + void.cat - Email Verification Code + + + +
+

void.cat

+

Your verification code is below please copy this to complete verification

+
@(Model?.Id.ToBase58() ?? "?????????????")
+
+ + \ No newline at end of file diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index 2541493..1b2b0e7 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -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(); services.AddVoidMigrations(); // file storage @@ -90,6 +93,7 @@ services.AddVoidPaywall(); // users services.AddTransient(); services.AddTransient(); +services.AddTransient(); if (useRedis) { @@ -129,6 +133,7 @@ app.UseEndpoints(ep => { ep.MapControllers(); ep.MapMetrics(); + ep.MapRazorPages(); #if HostSPA ep.MapFallbackToFile("index.html"); #endif diff --git a/VoidCat/Services/Abstractions/ICache.cs b/VoidCat/Services/Abstractions/ICache.cs index 2bac6d6..dff01f4 100644 --- a/VoidCat/Services/Abstractions/ICache.cs +++ b/VoidCat/Services/Abstractions/ICache.cs @@ -7,4 +7,6 @@ public interface ICache ValueTask GetList(string key); ValueTask AddToList(string key, string value); + + ValueTask Delete(string key); } diff --git a/VoidCat/Services/Abstractions/IEmailVerification.cs b/VoidCat/Services/Abstractions/IEmailVerification.cs new file mode 100644 index 0000000..8accfe3 --- /dev/null +++ b/VoidCat/Services/Abstractions/IEmailVerification.cs @@ -0,0 +1,10 @@ +using VoidCat.Model; + +namespace VoidCat.Services.Abstractions; + +public interface IEmailVerification +{ + ValueTask SendNewCode(PrivateVoidUser user); + + ValueTask VerifyCode(PrivateVoidUser user, Guid code); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserManager.cs b/VoidCat/Services/Abstractions/IUserManager.cs index 6303be0..615b5f4 100644 --- a/VoidCat/Services/Abstractions/IUserManager.cs +++ b/VoidCat/Services/Abstractions/IUserManager.cs @@ -4,6 +4,6 @@ namespace VoidCat.Services.Abstractions; public interface IUserManager { - ValueTask Login(string username, string password); - ValueTask Register(string username, string password); + ValueTask Login(string username, string password); + ValueTask Register(string username, string password); } diff --git a/VoidCat/Services/InMemory/InMemoryCache.cs b/VoidCat/Services/InMemory/InMemoryCache.cs index 175a19e..527e7f5 100644 --- a/VoidCat/Services/InMemory/InMemoryCache.cs +++ b/VoidCat/Services/InMemory/InMemoryCache.cs @@ -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;; + } } diff --git a/VoidCat/Services/Redis/RedisCache.cs b/VoidCat/Services/Redis/RedisCache.cs index 3907e38..f1f87f2 100644 --- a/VoidCat/Services/Redis/RedisCache.cs +++ b/VoidCat/Services/Redis/RedisCache.cs @@ -34,4 +34,9 @@ public class RedisCache : ICache { await _db.SetAddAsync(key, value); } + + public async ValueTask Delete(string key) + { + await _db.KeyDeleteAsync(key); + } } diff --git a/VoidCat/Services/Users/EmailVerification.cs b/VoidCat/Services/Users/EmailVerification.cs new file mode 100644 index 0000000..b909c8c --- /dev/null +++ b/VoidCat/Services/Users/EmailVerification.cs @@ -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 _logger; + private readonly RazorPartialToStringRenderer _renderer; + + public EmailVerification(ICache cache, ILogger logger, VoidSettings settings, RazorPartialToStringRenderer renderer) + { + _cache = cache; + _logger = logger; + _settings = settings; + _renderer = renderer; + } + + public async ValueTask 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 VerifyCode(PrivateVoidUser user, Guid code) + { + var token = await _cache.Get(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}"; +} \ No newline at end of file diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index b52e97c..43a3739 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -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 Login(string email, string password) + public async ValueTask 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 Register(string email, string password) + public async ValueTask 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; } } diff --git a/VoidCat/Services/ViewRenderer.cs b/VoidCat/Services/ViewRenderer.cs new file mode 100644 index 0000000..18e13eb --- /dev/null +++ b/VoidCat/Services/ViewRenderer.cs @@ -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 RenderPartialToStringAsync(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( + 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()); + } +} \ No newline at end of file diff --git a/VoidCat/VoidCat.csproj b/VoidCat/VoidCat.csproj index ae35c62..6b10c79 100644 --- a/VoidCat/VoidCat.csproj +++ b/VoidCat/VoidCat.csproj @@ -35,6 +35,9 @@ + + + diff --git a/VoidCat/spa/src/Api.js b/VoidCat/spa/src/Api.js index b75a8f0..f6ce9bc 100644 --- a/VoidCat/spa/src/Api.js +++ b/VoidCat/spa/src/Api.js @@ -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) } }; } \ No newline at end of file diff --git a/VoidCat/spa/src/Const.js b/VoidCat/spa/src/Const.js index d6041b1..0c1bdf1 100644 --- a/VoidCat/spa/src/Const.js +++ b/VoidCat/spa/src/Const.js @@ -69,5 +69,6 @@ export const PageSortOrder = { export const UserFlags = { PublicProfile: 1, - PublicUploads: 2 + PublicUploads: 2, + EmailVerified: 4 } \ No newline at end of file diff --git a/VoidCat/spa/src/Dropzone.js b/VoidCat/spa/src/Dropzone.js index 1379524..fc9b8da 100644 --- a/VoidCat/spa/src/Dropzone.js +++ b/VoidCat/spa/src/Dropzone.js @@ -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(); diff --git a/VoidCat/spa/src/FilePaywall.js b/VoidCat/spa/src/FilePaywall.js index 4f40931..90b26b1 100644 --- a/VoidCat/spa/src/FilePaywall.js +++ b/VoidCat/spa/src/FilePaywall.js @@ -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() { diff --git a/VoidCat/spa/src/Login.js b/VoidCat/spa/src/Login.js index 2dbc11e..6898e13 100644 --- a/VoidCat/spa/src/Login.js +++ b/VoidCat/spa/src/Login.js @@ -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() {

Login

Username:
-
setUsername(e.target.value)} placeholder="user@example.com"/>
+
setUsername(e.target.value)} placeholder="user@example.com"/>
Password:
setPassword(e.target.value)}/>
- - +
login(e, Api.login)}>Login
+
login(e, Api.register)}>Register
{error ?
{error}
: null} ); diff --git a/VoidCat/spa/src/NoPaywallConfig.js b/VoidCat/spa/src/NoPaywallConfig.js index 7caa880..5ea9d6a 100644 --- a/VoidCat/spa/src/NoPaywallConfig.js +++ b/VoidCat/spa/src/NoPaywallConfig.js @@ -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 (
- +
Save
{saveStatus ? : null}
) diff --git a/VoidCat/spa/src/Profile.js b/VoidCat/spa/src/Profile.js index c043df9..351c55e 100644 --- a/VoidCat/spa/src/Profile.js +++ b/VoidCat/spa/src/Profile.js @@ -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 ( + +

Please enter email verification code

+ Your account will automatically be deleted in 7 days if you do not verify your email + address. +
+ setEmailCode(e.target.value)}/> +
Submit
+
dispatch(logout())}>Logout
+
+ {emailCodeError ? {emailCodeError} : null} + {emailCodeError && !newCodeSent ? Send verfication email : null} +
+ ); + } + + function renderProfileEdit() { + return ( + +
+
Public Profile:
+
+ toggleFlag(UserFlags.PublicProfile)}/> +
+
Public Uploads:
+
+ toggleFlag(UserFlags.PublicUploads)}/> +
+ +
+
+
+
Save
+
+
+ {saved ? : null} +
+
+
dispatch(logout())}>Logout
+
+
+
+ ); } useEffect(() => { @@ -124,7 +203,7 @@ export function Profile() {
- {canEdit ? + {cantEditProfile ? editUsername(e.target.value)}/> : profile.displayName} @@ -132,7 +211,7 @@ export function Profile() {
- {canEdit ?
changeAvatar()}> + {cantEditProfile ?
changeAvatar()}>

Edit

: null}
@@ -146,33 +225,8 @@ export function Profile() {
- {canEdit ? - -
-
Public Profile:
-
- toggleFlag(UserFlags.PublicProfile)}/> -
-
Public Uploads:
-
- toggleFlag(UserFlags.PublicUploads)}/> -
- -
-
-
-
Save
-
-
- {saved ? : null} -
-
-
dispatch(logout())}>Logout
-
-
-
: null} + {cantEditProfile ? renderProfileEdit() : null} + {needsEmailVerify ? renderEmailVerify() : null}

Uploads

Api.listUserFiles(profile.id, req)}/>
diff --git a/VoidCat/spa/src/StrikePaywallConfig.js b/VoidCat/spa/src/StrikePaywallConfig.js index 59ea8cd..9c165b4 100644 --- a/VoidCat/spa/src/StrikePaywallConfig.js +++ b/VoidCat/spa/src/StrikePaywallConfig.js @@ -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) {
Price:
setPrice(parseFloat(e.target.value))}/>
- +
Save
{saveStatus ? : null}
); diff --git a/VoidCat/spa/src/Util.js b/VoidCat/spa/src/Util.js index bf8d268..7ae8f50 100644 --- a/VoidCat/spa/src/Util.js +++ b/VoidCat/spa/src/Util.js @@ -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"); } \ No newline at end of file diff --git a/VoidCat/spa/src/index.css b/VoidCat/spa/src/index.css index b711eb1..6575e98 100644 --- a/VoidCat/spa/src/index.css +++ b/VoidCat/spa/src/index.css @@ -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; } \ No newline at end of file