Add email verification

Standardize button styles
This commit is contained in:
Kieran 2022-03-02 11:37:15 +00:00
parent c2c6b92ce6
commit 72823ffedd
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
25 changed files with 480 additions and 92 deletions

View File

@ -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);
}
}

View 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; }
}

View File

@ -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();

View File

@ -76,5 +76,6 @@ public sealed class PublicVoidUser : VoidUser
public enum VoidUserFlags
{
PublicProfile = 1,
PublicUploads = 2
PublicUploads = 2,
EmailVerified = 4
}

View 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>

View File

@ -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

View File

@ -7,4 +7,6 @@ public interface ICache
ValueTask<string[]> GetList(string key);
ValueTask AddToList(string key, string value);
ValueTask Delete(string key);
}

View 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);
}

View File

@ -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);
}

View File

@ -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;;
}
}

View File

@ -34,4 +34,9 @@ public class RedisCache : ICache
{
await _db.SetAddAsync(key, value);
}
public async ValueTask Delete(string key)
{
await _db.KeyDeleteAsync(key);
}
}

View 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}";
}

View File

@ -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;
}
}

View 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());
}
}

View File

@ -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 -->

View File

@ -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)
}
};
}

View File

@ -69,5 +69,6 @@ export const PageSortOrder = {
export const UserFlags = {
PublicProfile: 1,
PublicUploads: 2
PublicUploads: 2,
EmailVerified: 4
}

View File

@ -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();

View File

@ -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() {

View File

@ -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>
);

View File

@ -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>
)

View File

@ -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>

View File

@ -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>
);

View File

@ -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");
}

View File

@ -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;
}