Added user list, updated stuff

This commit is contained in:
the1mason 2025-04-05 03:04:46 +05:00
parent f503166d9c
commit 1bba76b71a
21 changed files with 508 additions and 94 deletions

View File

@ -6,6 +6,7 @@ namespace FastBlog.Core.Abstractions.Repositories.Users;
public interface IUserRepository public interface IUserRepository
{ {
Task<User?> Get(int id); Task<User?> Get(int id);
Task<UserPassword?> GetWithPassword(int id);
Task<UserPassword?> GetByName(string username); Task<UserPassword?> GetByName(string username);
Task<bool> SetPassword(int id, string newPassword); Task<bool> SetPassword(int id, string newPassword);
Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest); Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest);

View File

@ -91,7 +91,7 @@ public static class DependencyInjection
{ {
var userService = provider.GetRequiredService<UserService>(); var userService = provider.GetRequiredService<UserService>();
var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0)); var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0));
if (existingUserResult.Amount is 1) if (existingUserResult.Amount >= 1)
return; return;
var newUser = new NewUser var newUser = new NewUser

View File

@ -34,4 +34,16 @@
<PackageReference Include="SQLite" Version="3.13.0" /> <PackageReference Include="SQLite" Version="3.13.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
</ItemGroup>
<ItemGroup>
<None Remove="obj\**" />
</ItemGroup>
</Project> </Project>

View File

@ -16,10 +16,10 @@ public class SessionOptions
(bool slidingValid, bool absoluteValid) = (true, true); (bool slidingValid, bool absoluteValid) = (true, true);
if (EnableAbsoluteExpiration) if (EnableAbsoluteExpiration)
slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) < DateTime.UtcNow; slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) > DateTime.UtcNow;
if (EnableSlidingExpiration) if (EnableSlidingExpiration)
absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) < DateTime.UtcNow; absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) > DateTime.UtcNow;
return (slidingValid, absoluteValid); return (slidingValid, absoluteValid);
} }

View File

@ -15,16 +15,23 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id }); return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id });
} }
public async Task<UserPassword?> GetWithPassword(int id)
{
const string sql = "select * from Users where id = @id and deleted is false";
using var connection = connectionFactory.Create();
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { id });
}
public async Task<UserPassword?> GetByName(string username) public async Task<UserPassword?> GetByName(string username)
{ {
const string sql = "select * from Users where username = @username and deleted is false"; const string sql = "select * from Users where username = @username";
using var connection = connectionFactory.Create(); using var connection = connectionFactory.Create();
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { username }); return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { username });
} }
public async Task<bool> SetPassword(int id, string newPassword) public async Task<bool> SetPassword(int id, string newPassword)
{ {
const string sql = "update Users set passwordHash = @newPassword where id = @id"; const string sql = "update Users set passwordHash = @newPassword where id = @id and deleted = 0";
using var connection = connectionFactory.Create(); using var connection = connectionFactory.Create();
return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0; return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0;
} }
@ -34,6 +41,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
const string sql = """ const string sql = """
select COUNT(*) from Users; select COUNT(*) from Users;
select * from Users select * from Users
where deleted = 0
limit @Amount offset @Offset; limit @Amount offset @Offset;
"""; """;
using var connection = connectionFactory.Create(); using var connection = connectionFactory.Create();
@ -53,7 +61,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
public async Task<bool> DeleteUser(int userId) public async Task<bool> DeleteUser(int userId)
{ {
const string sql = "update Users set deleted = true where id = @userId"; const string sql = "update Users set deleted = true where id = @userId and deleted = 0";
using var connection = connectionFactory.Create(); using var connection = connectionFactory.Create();
return await connection.ExecuteAsync(sql, new { userId }) > 0; return await connection.ExecuteAsync(sql, new { userId }) > 0;
} }

View File

@ -8,16 +8,41 @@ public sealed class UserService(IUserRepository repository)
{ {
public Task<User?> Get(int id) => repository.Get(id); public Task<User?> Get(int id) => repository.Get(id);
public async Task<User?> GetByName(string username)
{
return User.FromUserPassword(await repository.GetByName(username));
}
public async Task<User?> GetByCredentials(string username, string password) public async Task<User?> GetByCredentials(string username, string password)
{ {
var user = await repository.GetByName(username); var user = await repository.GetByName(username);
if (user is null) return null; if (user is null || user.Deleted) return null;
return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user); return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user);
} }
public Task<bool> SetPassword(int id, string newPassword) => public sealed record ChangePasswordResult(bool Success, string Message);
repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)); public async Task<ChangePasswordResult> ChangePassword(int id, string oldPassword, string newPassword)
{
var user = await repository.GetWithPassword(id);
if (user is null)
return new(false, "Unknown user");
if (!BCrypt.Net.BCrypt.Verify(oldPassword, user.PasswordHash))
return new(false, "Wrong password");
if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)))
return new(false, "Couldn't update password");
return new(true, "Successfully updated the password");
}
public async Task<ChangePasswordResult> SetPassword(int id, string newPassword)
{
var user = await repository.GetWithPassword(id);
if (user is null)
return new(false, "Unknown user");
if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)))
return new(false, "Couldn't update password");
return new(true, "Successfully updated the password");
}
public Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest); public Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest);

View File

@ -13,6 +13,7 @@ public class FilesController(FileService fileService) : Controller
private const long MaxFileSize = 8L * 1024L * 1024L * 1024L; private const long MaxFileSize = 8L * 1024L * 1024L * 1024L;
[HttpGet] [HttpGet]
[SimpleAuth]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var files = await fileService.GetFiles(new PagedRequest(25, 0)); var files = await fileService.GetFiles(new PagedRequest(25, 0));
@ -20,6 +21,7 @@ public class FilesController(FileService fileService) : Controller
} }
[HttpGet("{offset:int}")] [HttpGet("{offset:int}")]
[SimpleAuth]
public async Task<IActionResult> Page(int offset) public async Task<IActionResult> Page(int offset)
{ {
var files = await fileService.GetFiles(new PagedRequest(25, offset)); var files = await fileService.GetFiles(new PagedRequest(25, offset));
@ -27,6 +29,7 @@ public class FilesController(FileService fileService) : Controller
} }
[HttpDelete("{metaId:int}")] [HttpDelete("{metaId:int}")]
[SimpleAuth]
public async Task<IActionResult> Delete(int metaId) public async Task<IActionResult> Delete(int metaId)
{ {
var result = await fileService.DeleteFile(metaId); var result = await fileService.DeleteFile(metaId);
@ -36,6 +39,7 @@ public class FilesController(FileService fileService) : Controller
[HttpPost] [HttpPost]
[SimpleAuth]
public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath) public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath)
{ {
// TODO: Validate path to prevent directory traversal // TODO: Validate path to prevent directory traversal

View File

@ -2,9 +2,9 @@
namespace FastBlog.Web.Controllers; namespace FastBlog.Web.Controllers;
public class RateLimitController : Controller public class TooManyRequestsController : Controller
{ {
[HttpGet("/RateLimited")] [HttpGet("/too-many-requests")]
public IActionResult Index() public IActionResult Index()
{ {
return View(); return View();

View File

@ -1,25 +1,41 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
using FastBlog.Core.Services; using FastBlog.Core.Services;
using FastBlog.Web.Middlewares;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace FastBlog.Web.Controllers; namespace FastBlog.Web.Controllers;
[EnableRateLimiting("fixed")]
public sealed class UsersController(UserService userService, SessionService sessionService) : Controller public sealed class UsersController(UserService userService, SessionService sessionService) : Controller
{ {
public UserService UserService { get; } = userService;
public sealed record LoginRequest(string? Login, string? Password); public sealed record LoginRequest(string? Login, string? Password);
public sealed record CreateRequest(string Login, string Password);
public sealed record LoginModel(string? Login, string? Password, List<string> Errors); public sealed record LoginModel(string? Login, string? Password, List<string> Errors);
[HttpGet("/users/login")] [HttpGet("/users/login")]
public async Task<IActionResult> Login() public IActionResult LogIn()
{ {
if (HttpContext.IsUser())
return Redirect("/");
return View(new LoginModel(null, null, [])); return View(new LoginModel(null, null, []));
} }
[Htmx]
[EnableRateLimiting("fixed")]
[HttpPost("/users/login")] [HttpPost("/users/login")]
public async Task<IActionResult> Login(LoginRequest request) public async Task<IActionResult> LogIn(LoginRequest request)
{ {
if (HttpContext.IsUser())
return Redirect("/");
List<string> errors = []; List<string> errors = [];
// validation // validation
if (string.IsNullOrWhiteSpace(request.Login) if (string.IsNullOrWhiteSpace(request.Login)
@ -36,7 +52,7 @@ public sealed class UsersController(UserService userService, SessionService sess
if (errors.Count > 0) if (errors.Count > 0)
{ {
return View(new LoginModel(request.Login, request.Password, errors)); return PartialView(new LoginModel(request.Login, request.Password, errors));
} }
Debug.Assert(request.Login is not null); Debug.Assert(request.Login is not null);
@ -48,11 +64,124 @@ public sealed class UsersController(UserService userService, SessionService sess
if (session is null) if (session is null)
{ {
errors.Add("Unknown user or incorrect password"); errors.Add("Unknown user or incorrect password");
return View(new LoginModel(request.Login, request.Password, errors)); return PartialView(new LoginModel(request.Login, request.Password, errors));
} }
UpdateCookie(HttpContext, "session-t", session.Token); UpdateCookie(HttpContext, "session-t", session.Token);
HttpContext.Response.Headers["HX-Redirect"] = "/";
return Ok();
}
[SimpleAuth]
[Htmx]
[HttpDelete("/users/logout")]
public async ValueTask<IActionResult> LogOut()
{
if (!HttpContext.Request.Cookies.TryGetValue("session-t", out var sessionT))
return Redirect("/"); return Redirect("/");
await sessionService.Deactivate(sessionT);
HttpContext.Response.Cookies.Delete("session-t");
HttpContext.Response.Headers["HX-Redirect"] = "/";
return Ok();
}
[HttpGet("/users")]
[SimpleAuth]
public async Task<IActionResult> Page([FromQuery] int page = 0)
{
var users = await userService.GetUsers(new PagedRequest(25, page));
return View("Index", users);
}
[HttpDelete("/users/{id:int}")]
[SimpleAuth]
public async Task<IActionResult> Delete(int id)
{
_ = await userService.DeleteUser(id);
HttpContext.Response.Headers["HX-Redirect"] = "/users";
return Ok();
}
public sealed record UserViewModel(User User, bool Success = true, string? Message = null);
[SimpleAuth]
[HttpGet("/users/me")]
public IActionResult GetCurrent()
{
if (!HttpContext.TryGetUser(out var user))
{
return Redirect("/users/login");
}
return View("Profile", new UserViewModel(user));
}
[SimpleAuth]
[HttpPost("/users")]
public async Task<IActionResult> Create(CreateRequest createRequest)
{
if (await userService.GetByName(createRequest.Login) is not null)
{
var u = await userService.GetUsers(new PagedRequest(25, 0));
var v = View("Index", u);
v.ViewData.Add("error", "User already exists");
return v;
}
var user = await userService.CreateUser(new NewUser
{
Password = createRequest.Password,
Username = createRequest.Login
});
var users = await userService.GetUsers(new PagedRequest(25, 0));
return View("Index", users);
}
[SimpleAuth]
[HttpGet("/users/{id:int}")]
public async Task<IActionResult> Get(int id)
{
var user = await userService.Get(id);
if (user is null)
return Redirect("~/");
return View("Profile", new UserViewModel(user));
}
public sealed record ChangePasswordModel(string OldPassword, string NewPassword);
[SimpleAuth]
[HttpPost("/users/{id:int}/change-password")]
public async Task<IActionResult> ChangePassword(int id, ChangePasswordModel model)
{
var user = await userService.Get(id);
if (user is null)
return Redirect("~/");
var result = await userService.ChangePassword(id, model.OldPassword, model.NewPassword);
return View("Profile", new UserViewModel(user, result.Success, result.Message));
}
[SimpleAuth]
[HttpPost("/users/{id:int}/set-password")]
public async Task<IActionResult> SetPassword(int id, string newPassword)
{
if (!HttpContext.TryGetUser(out var currentUser))
{
return Redirect("~/");
}
if (currentUser.Id == id)
{
return View("Profile", new UserViewModel(currentUser, false, "Provide the old password too"));
}
var user = await userService.Get(id);
if (user is null)
return Redirect("~/");
var result = await userService.SetPassword(id, newPassword);
return View("Profile", new UserViewModel(user, result.Success, result.Message));
} }
private static void UpdateCookie(HttpContext context, string key, string value) private static void UpdateCookie(HttpContext context, string key, string value)
@ -62,8 +191,8 @@ public sealed class UsersController(UserService userService, SessionService sess
{ {
HttpOnly = true, HttpOnly = true,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Secure = true,
IsEssential = true IsEssential = true
// TODO: more options w/ configs, ill think bout that later
}); });
} }
} }

View File

@ -1,6 +1,9 @@
namespace FastBlog.Web; namespace FastBlog.Web;
public class HtmxAttribute /// <summary>
{ /// Marks methods used by HTMX requests<br/>
/// Use Redirect() with caution!<br/>
} /// Does not affect behaviour<br/>
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class HtmxAttribute : Attribute;

View File

@ -1,4 +1,5 @@
using FastBlog.Core.Models.Users; using System.Diagnostics.CodeAnalysis;
using FastBlog.Core.Models.Users;
using FastBlog.Core.Services; using FastBlog.Core.Services;
namespace FastBlog.Web.Middlewares; namespace FastBlog.Web.Middlewares;
@ -12,7 +13,7 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next)
if (sessionToken is null && meta is not null) if (sessionToken is null && meta is not null)
{ {
context.Response.Redirect("/user/login"); context.Response.Redirect("/");
return; return;
} }
@ -24,10 +25,10 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next)
var result = await service.Get(sessionToken!); var result = await service.Get(sessionToken!);
if (result.IsError) if (result.IsError || result.AsOk is null)
{ {
context.Response.Cookies.Delete("session"); context.Response.Cookies.Delete("session-t");
context.Response.Redirect("/user/login"); context.Response.Redirect("/users/login");
return; return;
} }
@ -46,7 +47,7 @@ internal class SimpleAuthAttribute : Attribute;
public static class SimpleAuthExtensions public static class SimpleAuthExtensions
{ {
public static bool TryGetUser(this HttpContext context, out User? user) public static bool TryGetUser(this HttpContext context, [NotNullWhen(true)] out User? user)
{ {
user = context.Features.Get<User>(); user = context.Features.Get<User>();
return user is not null; return user is not null;

View File

@ -1,5 +1,7 @@
using FastBlog.Core; using FastBlog.Core;
using FastBlog.Web; using FastBlog.Web;
using FastBlog.Web.Middlewares;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -11,8 +13,33 @@ else
builder.Services.AddCore(builder.Configuration); builder.Services.AddCore(builder.Configuration);
builder.Services.Configure<DisplayOptions>(builder.Configuration.GetSection("Display")); builder.Services.Configure<DisplayOptions>(builder.Configuration.GetSection("Display"));
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.AddFixedWindowLimiter(policyName: "fixed", options =>
{
options.PermitLimit = 30;
options.Window = TimeSpan.FromSeconds(300);
options.QueueLimit = 0;
})
.OnRejected += (context, _) =>
{
context.HttpContext.Response.StatusCode = 200;
if (context.HttpContext.Request.Headers.ContainsKey("HX-Request"))
{
context.HttpContext.Response.Headers.Append("HX-Redirect", "/too-many-requests");
}
else
{
context.HttpContext.Response.Redirect("/too-many-requests");
}
return ValueTask.CompletedTask;
};
});
var app = builder.Build(); var app = builder.Build();
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Home/Error"); app.UseExceptionHandler("/Home/Error");
@ -23,8 +50,12 @@ app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseRateLimiter();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<SimpleAuthMiddleware>();
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Blogs}/{action=Index}/{slug?}"); pattern: "{controller=Blogs}/{action=Index}/{slug?}");

View File

@ -1,7 +1,7 @@
@model FastBlog.Core.Models.Blogs.Blog @model FastBlog.Core.Models.Blogs.Blog
@{ @{
ViewBag.Title = Model.Metadata.Title; ViewData["Title"] = Model.Metadata.Title;
} }
@if(DateTime.UtcNow < Model.Metadata.CreatedAt) @if(DateTime.UtcNow < Model.Metadata.CreatedAt)

View File

@ -3,7 +3,7 @@
@{ @{
ViewBag.Title = "Files"; ViewData["Title"] = "Files";
} }
<div class="container" id="file-list"> <div class="container" id="file-list">
@ -22,15 +22,6 @@
<input type="file" name="file" required/> <input type="file" name="file" required/>
<label for="upload-path">Path to upload (optional)</label> <label for="upload-path">Path to upload (optional)</label>
<input type="text" id="upload-path" name="sourcePath" placeholder="/subfolders/"/> <input type="text" id="upload-path" name="sourcePath" placeholder="/subfolders/"/>
<input type="radio" id="age1" name="age" value="30">
<label for="age1">0 - 30</label><br>
<input type="radio" id="age2" name="age" value="60">
<label for="age2">31 - 60</label><br>
<input type="radio" id="age3" name="age" value="100">
<label for="age3">61 - 100</label><br><br>
<input type="submit" value="Submit">
<button type="submit" class="btn-fw"> <button type="submit" class="btn-fw">
Upload Upload
</button> </button>

View File

@ -1,16 +1,14 @@
@using Microsoft.Extensions.Options @using FastBlog.Web.Middlewares
@using Microsoft.Extensions.Options
@inject IOptions<DisplayOptions> options; @inject IOptions<DisplayOptions> options;
@{ @{
ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request"); ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request");
} }
@if (ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true")) @if (ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true"))
{ {
@RenderBody() @RenderBody()
return; return;
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark"> <html lang="en" data-theme="dark">
<head> <head>
@ -24,20 +22,52 @@ return;
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
</head> </head>
<body> <body>
<script src="~/js/htmx.min.js"></script> <script src="~/lib/htmx/htmx.2.min.js"></script>
<header class="nav-container"> <header>
@if (Context.IsUser())
{
<div class="nav-admin">
<nav>
<div> <div>
<li>
<a href="~/edit">New</a>
</li>
<li>
<a href="~/files">Files</a>
</li>
<li>
<a href="~/users">Users</a>
</li>
</div>
<div>
<li>
<a href="/users/me">Profile</a>
</li>
<li>
<a href="#" hx-delete="/users/logout">Logout</a>
</li>
</div>
</nav>
</div>
}
<div class="nav-container">
<nav> <nav>
<ul> <ul>
<li><a href="~/"><h1>@options.Value.Title</h1></a></li> <li>
<li><a href="~/list">Blog</a></li> <a href="~/">
<li><a href="~/edit">New</a></li> <h1>@options.Value.Title</h1>
<li><a href="~/files">Files</a></li> </a>
</li>
<li>
<a href="~/list">Blog</a>
</li>
</ul> </ul>
<ul> <ul>
@foreach (var link in options.Value.Links) @foreach (var link in options.Value.Links)
{ {
<li><a href="@link.Value">@link.Key</a></li> <li>
<a href="@link.Value">@link.Key</a>
</li>
} }
</ul> </ul>
</nav> </nav>

View File

@ -1,12 +1,12 @@
@{ @{
ViewBag.Title = "Rate limit exceeded"; ViewData["Title"] = "Rate limit exceeded";
} }
<div class="container"> <div class="container">
<article> <article>
<header>Rate limit exceeded!</header> <header>Rate limit exceeded</header>
<div> <div>
<p>You have sent too many requests!</p> <p>You have sent too many requests</p>
</div> </div>
</article> </article>
</div> </div>

View File

@ -0,0 +1,76 @@
@using FastBlog.Web.Helpers
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Users.User>
@{
ViewData["Title"] = "Users";
}
<div class="container" id="user-list">
<br/>
<article>
<header>
<h3>Create a user</h3>
</header>
@if (ViewData.TryGetValue("error", out var errorMsg))
{
<div class="alert alert-caution">@errorMsg</div>
}
<form
hx-post="/users"
hx-swap="outerHTML"
hx-target="#user-list">
<label for="login">Login</label>
<input required type="text" id="login" name="login" placeholder="username"/>
<label for="password">Password</label>
<input required type="password" id="password" name="password"/>
<button type="submit" class="btn-fw">
Create
</button>
</form>
</article>
<article>
<header>
<h3>Manage users</h3>
</header>
<body>
@if (Model.Data.Length is 0)
{
<h4>No users have been found</h4>
}
@foreach (var user in Model.Data)
{
<div class="grid">
<div style="display: flex; font-size: 16px">
<p class="no-margin" style="margin-right: 10px; font-weight: bold">
<a href="~/users/@user.Id/">@user.Username</a>
</p>
</div>
</div>
<hr/>
}
</body>
<footer>
<div>
<button
hx-get="/users?page=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/users?page=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>
</footer>
</article>
</div>

View File

@ -1,10 +1,9 @@
@model FastBlog.Web.Controllers.UsersController.LoginModel @model FastBlog.Web.Controllers.UsersController.LoginModel
@{ @{
ViewBag.Title = "Login"; ViewData["Title"] = "Login";
} }
<div id="login-form" class="container"> <div id="login-form" class="container">
<article >
<form <form
hx-post="/users/login" hx-post="/users/login"
hx-swap="outerHTML" hx-swap="outerHTML"
@ -18,7 +17,7 @@
</div> </div>
<div> <div>
<label for="login">Password:</label> <label for="login">Password:</label>
<input type="password" id="password" name="password" value="@Model.Login" <input type="password" id="password" name="password" value="@Model.Password"
minlength="3" minlength="3"
required> required>
</div> </div>
@ -29,7 +28,10 @@
</div> </div>
@foreach (var error in Model.Errors) @foreach (var error in Model.Errors)
{ {
<div class="alert-caution">error</div> <div class="alert alert-caution">
<p>@error</p>
</div>
} }
</form> </form>
</article>
</div> </div>

View File

@ -1,2 +1,86 @@
@model TModel @using FastBlog.Web.Middlewares
@model FastBlog.Web.Controllers.UsersController.UserViewModel
@{
ViewData["Title"] = $"{Model.User.Username} - Профиль";
bool isSelf = Context.TryGetUser(out var user) && user.Id == Model.User.Id;
}
<div class="container">
<div class="grid">
<article>
<header>
<h2>@Model.User.Username</h2>
</header>
<div class="grid">
@if (isSelf)
{
<p>This is your profile, @Model.User.Username</p>
}
else
{
<div>
<p>Now editing @Model.User.Username's profile</p>
@if (!Model.User.Deleted)
{
<button hx-delete="/users/@Model.User.Id">Delete profile</button>
}
</div>
}
</div>
</article>
@if (isSelf)
{
<article>
<header>Password</header>
<div>
<form action="~/users/@Model.User.Id/change-password" method="post">
<div>
<label for="oldPassword">Old password:</label>
<input type="password" id="oldPassword" name="oldPassword" required>
</div>
<div>
<label for="newPassword">New password:</label>
<input type="password" id="newPassword" name="newPassword" required>
</div>
<div>
<button type="submit">
<span>Update</span>
</button>
</div>
</form>
@if (Model.Message is not null)
{
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
<p>@Model.Message</p>
</div>
}
</div>
</article>
}
else
{
<article>
<header>Password</header>
<div>
<form action="~/users/@Model.User.Id/set-password" method="post">
<div>
<label for="newPassword">New password:</label>
<input type="password" id="newPassword" name="newPassword" required>
</div>
<div>
<button type="submit">
<span>Set</span>
</button>
</div>
</form>
@if (Model.Message is not null)
{
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
<p>@Model.Message</p>
</div>
}
</div>
</article>
}
</div>
</div>

View File

@ -93,6 +93,23 @@ body {
margin-bottom: 60px; margin-bottom: 60px;
} }
body>header {
margin: 0;
padding-block: 0;
}
.nav-admin {
margin: 0;
padding: 0 20px;
background: #0a121b;
border-bottom: solid 1px rgba(255, 255, 255, 0.09);
}
.nav-admin li {
margin: 0;
padding: 5px
}
.nav-container { .nav-container {
margin: 0 20px 20px; margin: 0 20px 20px;
padding-top: 0; padding-top: 0;