diff --git a/API/API.csproj b/API/API.csproj index a28a835..a541df4 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -11,10 +11,16 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/API/Contracts/Auth/AuthUserDto.cs b/API/Contracts/Auth/AuthUserDto.cs new file mode 100644 index 0000000..1edcc2e --- /dev/null +++ b/API/Contracts/Auth/AuthUserDto.cs @@ -0,0 +1,9 @@ +namespace API.Contracts.User; + +public class AuthUserDto +{ + public string DisplayName { get; set; } + public string Token { get; set; } + public string Image { get; set; } + public string Username { get; set; } +} diff --git a/API/Contracts/Auth/LoginDto.cs b/API/Contracts/Auth/LoginDto.cs new file mode 100644 index 0000000..d96c3fc --- /dev/null +++ b/API/Contracts/Auth/LoginDto.cs @@ -0,0 +1,7 @@ +namespace API.Contracts.Auth; + +public class LoginDto +{ + public string Email { get; set; } + public string Password { get; set; } +} diff --git a/API/Contracts/Auth/RegisterDto.cs b/API/Contracts/Auth/RegisterDto.cs new file mode 100644 index 0000000..a3cd662 --- /dev/null +++ b/API/Contracts/Auth/RegisterDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.Contracts.Auth; + +public class RegisterDto +{ + [Required] [EmailAddress] public string Email { get; set; } + + [Required] + [RegularExpression("(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{4,8}$", ErrorMessage = "Password must be complex")] + public string Password { get; set; } + + [Required] public string DisplayName { get; set; } + + [Required] public string Username { get; set; } +} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs new file mode 100644 index 0000000..9f7e1c8 --- /dev/null +++ b/API/Controllers/AccountController.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using API.Contracts.Auth; +using API.Contracts.User; +using API.Services.Auth; +using Domain.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AccountController(UserManager userManager, TokenService tokenService) : ControllerBase +{ + [AllowAnonymous] + [HttpPost("login")] + public async Task> Login(LoginDto loginDto) + { + var user = await userManager.FindByEmailAsync(loginDto.Email); + + if (user == null) return Unauthorized(); + + var result = await userManager.CheckPasswordAsync(user, loginDto.Password); + + if (result) return CreateUserObject(user); + + return Unauthorized(); + } + + [AllowAnonymous] + [HttpPost("register")] + public async Task> Register(RegisterDto registerDto) + { + if (await userManager.Users.AnyAsync(x => x.UserName == registerDto.Username)) + { + ModelState.AddModelError("username", "Username taken"); + return ValidationProblem(); + } + + + if (await userManager.Users.AnyAsync(x => x.Email == registerDto.Email)) + { + ModelState.AddModelError("email", "Email taken"); + return ValidationProblem(); + } + + + var user = new User + { + DisplayName = registerDto.DisplayName, + Email = registerDto.Email, + UserName = registerDto.Username + }; + + var result = await userManager.CreateAsync(user, registerDto.Password); + + if (result.Succeeded) return CreateUserObject(user); + + return BadRequest(result.Errors); + } + + [Authorize] + [HttpGet] + public async Task> GetCurrentUser() + { + var user = await userManager.FindByEmailAsync(User.FindFirstValue(ClaimTypes.Email)); + + return CreateUserObject(user); + } + + private AuthUserDto CreateUserObject(User user) + { + return new AuthUserDto + { + DisplayName = user.DisplayName, + Image = null, + Token = tokenService.CreateToken(user), + Username = user.UserName + }; + } +} diff --git a/API/Controllers/BuggyController.cs b/API/Controllers/BuggyController.cs index 2195098..2f07fc3 100644 --- a/API/Controllers/BuggyController.cs +++ b/API/Controllers/BuggyController.cs @@ -1,32 +1,30 @@ -using System; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +public class BuggyController : BaseApiController { - public class BuggyController : BaseApiController + [HttpGet("not-found")] + public ActionResult GetNotFound() { - [HttpGet("not-found")] - public ActionResult GetNotFound() - { - return NotFound(); - } + return NotFound(); + } - [HttpGet("bad-request")] - public ActionResult GetBadRequest() - { - return BadRequest("This is a bad request"); - } + [HttpGet("bad-request")] + public ActionResult GetBadRequest() + { + return BadRequest("This is a bad request"); + } - [HttpGet("server-error")] - public ActionResult GetServerError() - { - throw new Exception("This is a server error"); - } + [HttpGet("server-error")] + public ActionResult GetServerError() + { + throw new Exception("This is a server error"); + } - [HttpGet("unauthorised")] - public ActionResult GetUnauthorised() - { - return Unauthorized(); - } + [HttpGet("unauthorised")] + public ActionResult GetUnauthorised() + { + return Unauthorized(); } } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs new file mode 100644 index 0000000..71765f2 --- /dev/null +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -0,0 +1,38 @@ +using System.Text; +using API.Services.Auth; +using Domain.Entities; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Persistence; + +namespace API.Extensions; + +public static class IdentityServiceExtensions +{ + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) + { + services.AddIdentityCore(opt => + { + opt.Password.RequireNonAlphanumeric = false; + opt.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores(); + + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = false, + ValidateAudience = false + }; + }); + + services.AddScoped(); + + return services; + } +} diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index ad633df..8cacebd 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -29,7 +29,7 @@ public async Task InvokeAsync(HttpContext context) var response = env.IsDevelopment() ? new AppException(context.Response.StatusCode, ex.Message, ex.StackTrace) : new AppException(context.Response.StatusCode, "Internal Server Error"); - + var json = JsonSerializer.Serialize(response, JsonOptions); diff --git a/API/Program.cs b/API/Program.cs index 83e5ea2..0a2266b 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,5 +1,9 @@ using API.Extensions; using API.Middleware; +using Domain.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.EntityFrameworkCore; using Persistence; @@ -7,8 +11,13 @@ // Add services to the container. -builder.Services.AddControllers(); +builder.Services.AddControllers(opt => +{ + var policy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build(); + opt.Filters.Add(new AuthorizeFilter(policy)); +}); builder.Services.AddApplicationServices(builder.Configuration); +builder.Services.AddIdentityServices(builder.Configuration); var app = builder.Build(); @@ -17,6 +26,7 @@ app.UseCors("CorsPolicy"); +app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); @@ -27,8 +37,9 @@ try { var context = services.GetRequiredService(); + var userManager = services.GetRequiredService>(); await context.Database.MigrateAsync(); - await Seed.SeedData(context); + await Seed.SeedData(context, userManager); } catch (Exception e) { diff --git a/API/Services/Auth/TokenService.cs b/API/Services/Auth/TokenService.cs new file mode 100644 index 0000000..a52f1aa --- /dev/null +++ b/API/Services/Auth/TokenService.cs @@ -0,0 +1,36 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Domain.Entities; +using Microsoft.IdentityModel.Tokens; + +namespace API.Services.Auth; + +public class TokenService(IConfiguration config) +{ + public string CreateToken(User user) + { + var claims = new List + { + new(ClaimTypes.Name, user.UserName), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.AddDays(7), + SigningCredentials = creds + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } +} diff --git a/API/activity-hub.db b/API/activity-hub.db index dc5d484..fc9d371 100644 Binary files a/API/activity-hub.db and b/API/activity-hub.db differ diff --git a/API/activity-hub.db-shm b/API/activity-hub.db-shm index 5a96818..d6d7fdf 100644 Binary files a/API/activity-hub.db-shm and b/API/activity-hub.db-shm differ diff --git a/API/activity-hub.db-wal b/API/activity-hub.db-wal index 1195991..b264658 100644 Binary files a/API/activity-hub.db-wal and b/API/activity-hub.db-wal differ diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index 4be8d50..9bd68a8 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -7,5 +7,6 @@ }, "ConnectionStrings": { "DefaultConnection": "Data Source=activity-hub.db" - } + }, + "TokenKey": "887aa8d55236124da3ae8da02e0809d2140470eb9aa6583686fafed91b9586eb" } diff --git a/Application/Core/Result.cs b/Application/Core/Result.cs index e2696a2..4290b16 100644 --- a/Application/Core/Result.cs +++ b/Application/Core/Result.cs @@ -8,11 +8,11 @@ public class Result public static Result Success(T value) { - return new Result() { IsSuccess = true, Value = value }; + return new Result { IsSuccess = true, Value = value }; } public static Result Failure(string error) { - return new Result() { IsSuccess = false, Error = error }; + return new Result { IsSuccess = false, Error = error }; } } diff --git a/Domain/Domain.csproj b/Domain/Domain.csproj index 2292000..70cb6fe 100644 --- a/Domain/Domain.csproj +++ b/Domain/Domain.csproj @@ -6,4 +6,8 @@ disable + + + + diff --git a/Domain/Entities/User.cs b/Domain/Entities/User.cs new file mode 100644 index 0000000..3ed7994 --- /dev/null +++ b/Domain/Entities/User.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Identity; + +namespace Domain.Entities; + +public class User : IdentityUser +{ + public string DisplayName { get; set; } + public string Bio { get; set; } +} diff --git a/Persistence/DataContext.cs b/Persistence/DataContext.cs index 2888a52..84ec6cb 100644 --- a/Persistence/DataContext.cs +++ b/Persistence/DataContext.cs @@ -1,9 +1,10 @@ using Domain.Entities; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Persistence; -public class DataContext(DbContextOptions options) : DbContext(options) +public class DataContext(DbContextOptions options) : IdentityDbContext(options) { public DbSet Activities { get; set; } } diff --git a/Persistence/Migrations/20240805112415_InitialCreate.Designer.cs b/Persistence/Migrations/20240805112415_InitialCreate.Designer.cs deleted file mode 100644 index 04bdf9d..0000000 --- a/Persistence/Migrations/20240805112415_InitialCreate.Designer.cs +++ /dev/null @@ -1,54 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Persistence; - -#nullable disable - -namespace Persistence.Migrations -{ - [DbContext(typeof(DataContext))] - [Migration("20240805112415_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); - - modelBuilder.Entity("Domain.features.activity.Activity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Category") - .HasColumnType("TEXT"); - - b.Property("City") - .HasColumnType("TEXT"); - - b.Property("Date") - .HasColumnType("TEXT"); - - b.Property("Description") - .HasColumnType("TEXT"); - - b.Property("Title") - .HasColumnType("TEXT"); - - b.Property("Venue") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Activities"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Persistence/Migrations/20240805112415_InitialCreate.cs b/Persistence/Migrations/20240805112415_InitialCreate.cs deleted file mode 100644 index 04f8673..0000000 --- a/Persistence/Migrations/20240805112415_InitialCreate.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Activities", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - Title = table.Column(type: "TEXT", nullable: true), - Date = table.Column(type: "TEXT", nullable: false), - Description = table.Column(type: "TEXT", nullable: true), - Category = table.Column(type: "TEXT", nullable: true), - City = table.Column(type: "TEXT", nullable: true), - Venue = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Activities", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Activities"); - } - } -} diff --git a/Persistence/Migrations/20240820065605_InitialCreate.Designer.cs b/Persistence/Migrations/20240820065605_InitialCreate.Designer.cs new file mode 100644 index 0000000..ef127ec --- /dev/null +++ b/Persistence/Migrations/20240820065605_InitialCreate.Designer.cs @@ -0,0 +1,303 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Persistence; + +#nullable disable + +namespace Persistence.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20240820065605_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Domain.Entities.Activity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("City") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Venue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Activities"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Persistence/Migrations/20240820065605_InitialCreate.cs b/Persistence/Migrations/20240820065605_InitialCreate.cs new file mode 100644 index 0000000..09c0dd7 --- /dev/null +++ b/Persistence/Migrations/20240820065605_InitialCreate.cs @@ -0,0 +1,244 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Activities", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: true), + Date = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + Category = table.Column(type: "TEXT", nullable: true), + City = table.Column(type: "TEXT", nullable: true), + Venue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Activities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", nullable: true), + Bio = table.Column(type: "TEXT", nullable: true), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Activities"); + + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/Persistence/Migrations/DataContextModelSnapshot.cs b/Persistence/Migrations/DataContextModelSnapshot.cs index 228347e..bcfb0f2 100644 --- a/Persistence/Migrations/DataContextModelSnapshot.cs +++ b/Persistence/Migrations/DataContextModelSnapshot.cs @@ -15,9 +15,9 @@ partial class DataContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); - modelBuilder.Entity("Domain.features.activity.Activity", b => + modelBuilder.Entity("Domain.Entities.Activity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -45,6 +45,255 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Activities"); }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/Persistence/Persistence.csproj b/Persistence/Persistence.csproj index 576cfcf..94f0e4a 100644 --- a/Persistence/Persistence.csproj +++ b/Persistence/Persistence.csproj @@ -5,7 +5,11 @@ - + + + + + diff --git a/Persistence/Seed.cs b/Persistence/Seed.cs index 3775928..d12599d 100644 --- a/Persistence/Seed.cs +++ b/Persistence/Seed.cs @@ -1,11 +1,24 @@ using Domain.Entities; +using Microsoft.AspNetCore.Identity; namespace Persistence; public class Seed { - public static async Task SeedData(DataContext context) + public static async Task SeedData(DataContext context, UserManager userManager) { + if (!userManager.Users.Any()) + { + var users = new List + { + new() { DisplayName = "Bob", UserName = "bob", Email = "bob@test.com" }, + new() { DisplayName = "Tom", UserName = "tom", Email = "tom@test.com" }, + new() { DisplayName = "Jane", UserName = "jane", Email = "jane@test.com" } + }; + + foreach (var user in users) await userManager.CreateAsync(user, "Pa$$w0rd"); + } + if (context.Activities.Any()) return; var activities = new List diff --git a/client-app/src/app/api/agent.ts b/client-app/src/app/api/agent.ts index e0d18ab..bb354b7 100644 --- a/client-app/src/app/api/agent.ts +++ b/client-app/src/app/api/agent.ts @@ -3,6 +3,7 @@ import { toast } from 'react-toastify'; import { Activity } from '../models/activity'; import { router } from '../router/Routes'; import { store } from '../stores/store'; +import { User, UserFormValues } from '../models/user'; const sleep = (delay: number) => { return new Promise((resolve) => setTimeout(resolve, delay)); @@ -10,6 +11,14 @@ const sleep = (delay: number) => { axios.defaults.baseURL = 'http://localhost:5000/api'; +const responseBody = (response: AxiosResponse) => response.data; + +axios.interceptors.request.use((config) => { + const token = store.commonStore.token; + if (token && config.headers) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + axios.interceptors.response.use( async (response) => { await sleep(1000); @@ -55,8 +64,6 @@ axios.interceptors.response.use( } ); -const responseBody = (response: AxiosResponse) => response.data; - const requests = { get: (url: string) => axios.get(url).then(responseBody), post: (url: string, body: object) => @@ -75,8 +82,16 @@ const Activities = { delete: (id: string) => requests.del(`/activities/${id}`), }; +const Account = { + current: () => requests.get('account'), + login: (user: UserFormValues) => requests.post('/account/login', user), + register: (user: UserFormValues) => + requests.post('/account/register', user), +}; + const agent = { Activities, + Account, }; export default agent; diff --git a/client-app/src/app/common/form/TextInput.tsx b/client-app/src/app/common/form/TextInput.tsx index 85824e9..c9d78d5 100644 --- a/client-app/src/app/common/form/TextInput.tsx +++ b/client-app/src/app/common/form/TextInput.tsx @@ -5,6 +5,7 @@ interface Props { placeholder: string; name: string; label?: string; + type?: string; } export default function TextInput(props: Props) { diff --git a/client-app/src/app/common/modals/ModalContainer.tsx b/client-app/src/app/common/modals/ModalContainer.tsx new file mode 100644 index 0000000..6fe36d5 --- /dev/null +++ b/client-app/src/app/common/modals/ModalContainer.tsx @@ -0,0 +1,16 @@ +import { observer } from 'mobx-react-lite'; +import { Modal } from 'semantic-ui-react'; +import { useStore } from '../../stores/store'; + +export default observer(function ModalContainer() { + const { modalStore } = useStore(); + return ( + + {modalStore.modal.body} + + ); +}); diff --git a/client-app/src/app/layout/App.tsx b/client-app/src/app/layout/App.tsx index 948033f..bfc7f68 100644 --- a/client-app/src/app/layout/App.tsx +++ b/client-app/src/app/layout/App.tsx @@ -4,12 +4,29 @@ import { observer } from 'mobx-react-lite'; import { Outlet, useLocation } from 'react-router-dom'; import HomePage from '../../features/home/HomePage'; import { ToastContainer } from 'react-toastify'; +import { useStore } from '../stores/store'; +import { useEffect } from 'react'; +import LoadingComponent from './LoadingComponent'; +import ModalContainer from '../common/modals/ModalContainer'; function App() { const location = useLocation(); + const { commonStore, userStore } = useStore(); + + useEffect(() => { + if (commonStore.token) { + userStore.getUser().finally(() => commonStore.setAppLoaded()); + } else { + commonStore.setAppLoaded(); + } + }, [commonStore, userStore]); + + if (!commonStore.appLoaded) + return ; return ( <> + {location.pathname === '/' ? ( diff --git a/client-app/src/app/layout/NavBar.tsx b/client-app/src/app/layout/NavBar.tsx index e244d27..a02a840 100644 --- a/client-app/src/app/layout/NavBar.tsx +++ b/client-app/src/app/layout/NavBar.tsx @@ -1,7 +1,12 @@ -import { Button, Container, Menu } from 'semantic-ui-react'; -import { NavLink } from 'react-router-dom'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '../stores/store'; +import { Button, Container, Dropdown, Menu, Image } from 'semantic-ui-react'; +import { Link, NavLink } from 'react-router-dom'; -export default function NavBar() { +export default observer(function NavBar() { + const { + userStore: { user, logout }, + } = useStore(); return ( @@ -10,7 +15,7 @@ export default function NavBar() { Activity Hub - + ); -} +}); diff --git a/client-app/src/app/models/user.ts b/client-app/src/app/models/user.ts new file mode 100644 index 0000000..109285d --- /dev/null +++ b/client-app/src/app/models/user.ts @@ -0,0 +1,13 @@ +export interface User { + username: string; + displayName: string; + token: string; + image?: string; +} + +export interface UserFormValues { + email: string; + password: string; + displayName?: string; + username?: string; +} diff --git a/client-app/src/app/router/Routes.tsx b/client-app/src/app/router/Routes.tsx index 7de8b4c..8b5f4dd 100644 --- a/client-app/src/app/router/Routes.tsx +++ b/client-app/src/app/router/Routes.tsx @@ -6,6 +6,7 @@ import ActivityDetails from '../../features/activities/details/ActivityDetails'; import TestErrors from '../../features/errors/TestError'; import NotFound from '../../features/errors/NotFound'; import ServerError from '../../features/errors/ServerError'; +import LoginForm from '../../features/users/LoginForm'; export const routes: RouteObject[] = [ { @@ -16,6 +17,7 @@ export const routes: RouteObject[] = [ { path: 'activities/:id', element: }, { path: 'createActivity', element: }, { path: 'manage/:id', element: }, + { path: 'login', element: }, { path: 'errors', element: }, { path: 'not-found', element: }, { path: 'server-error', element: }, diff --git a/client-app/src/app/stores/commonStore.ts b/client-app/src/app/stores/commonStore.ts index f8d44e6..57f3d8e 100644 --- a/client-app/src/app/stores/commonStore.ts +++ b/client-app/src/app/stores/commonStore.ts @@ -1,14 +1,35 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, reaction } from 'mobx'; import { ServerError } from '../models/serverError'; export default class CommonStore { error: ServerError | null = null; + token: string | null = localStorage.getItem('jwt'); + appLoaded = false; constructor() { makeAutoObservable(this); + + reaction( + () => this.token, + (token) => { + if (token) { + localStorage.setItem('jwt', token); + } else { + localStorage.removeItem('jwt'); + } + } + ); } setServerError(error: ServerError) { this.error = error; } + + setToken = (token: string | null) => { + this.token = token; + }; + + setAppLoaded = () => { + this.appLoaded = true; + }; } diff --git a/client-app/src/app/stores/modalStore.ts b/client-app/src/app/stores/modalStore.ts new file mode 100644 index 0000000..e18cce6 --- /dev/null +++ b/client-app/src/app/stores/modalStore.ts @@ -0,0 +1,27 @@ +import { makeAutoObservable } from 'mobx'; + +interface Modal { + open: boolean; + body: JSX.Element | null; +} + +export default class ModalStore { + modal: Modal = { + open: false, + body: null, + }; + + constructor() { + makeAutoObservable(this); + } + + openModal = (content: JSX.Element) => { + this.modal.open = true; + this.modal.body = content; + }; + + closeModal = () => { + this.modal.open = false; + this.modal.body = null; + }; +} diff --git a/client-app/src/app/stores/store.ts b/client-app/src/app/stores/store.ts index 2838028..4285f1d 100644 --- a/client-app/src/app/stores/store.ts +++ b/client-app/src/app/stores/store.ts @@ -1,15 +1,21 @@ import ActivityStore from './activityStore'; import { createContext, useContext } from 'react'; import CommonStore from './commonStore'; +import UserStore from './userStore'; +import ModalStore from './modalStore'; interface Store { activityStore: ActivityStore; commonStore: CommonStore; + userStore: UserStore; + modalStore: ModalStore; } export const store: Store = { activityStore: new ActivityStore(), commonStore: new CommonStore(), + userStore: new UserStore(), + modalStore: new ModalStore(), }; export const StoreContext = createContext(store); diff --git a/client-app/src/app/stores/userStore.ts b/client-app/src/app/stores/userStore.ts new file mode 100644 index 0000000..67b3efe --- /dev/null +++ b/client-app/src/app/stores/userStore.ts @@ -0,0 +1,48 @@ +import { makeAutoObservable, runInAction } from 'mobx'; +import agent from '../api/agent'; +import { User, UserFormValues } from '../models/user'; +import { router } from '../router/Routes'; +import { store } from './store'; + +export default class UserStore { + user: User | null = null; + + constructor() { + makeAutoObservable(this); + } + + get isLoggedIn() { + return !!this.user; + } + + login = async (creds: UserFormValues) => { + const user = await agent.Account.login(creds); + store.commonStore.setToken(user.token); + runInAction(() => (this.user = user)); + await router.navigate('/activities'); + store.modalStore.closeModal(); + }; + + register = async (creds: UserFormValues) => { + const user = await agent.Account.register(creds); + store.commonStore.setToken(user.token); + runInAction(() => (this.user = user)); + await router.navigate('/activities'); + store.modalStore.closeModal(); + }; + + logout = async () => { + store.commonStore.setToken(null); + this.user = null; + await router.navigate('/'); + }; + + getUser = async () => { + try { + const user = await agent.Account.current(); + runInAction(() => (this.user = user)); + } catch (error) { + console.log(error); + } + }; +} diff --git a/client-app/src/features/activities/dashboard/ActivityDashboard.tsx b/client-app/src/features/activities/dashboard/ActivityDashboard.tsx index 1679a3f..cf30478 100644 --- a/client-app/src/features/activities/dashboard/ActivityDashboard.tsx +++ b/client-app/src/features/activities/dashboard/ActivityDashboard.tsx @@ -15,7 +15,7 @@ export default observer(function ActivityDashboard() { }, [activityRegistry.size, loadActivities]); if (activityStore.loadingInitial) - return ; + return ; return ( diff --git a/client-app/src/features/home/HomePage.tsx b/client-app/src/features/home/HomePage.tsx index 5db559e..20f0510 100644 --- a/client-app/src/features/home/HomePage.tsx +++ b/client-app/src/features/home/HomePage.tsx @@ -1,7 +1,12 @@ +import { observer } from 'mobx-react-lite'; import { Link } from 'react-router-dom'; import { Button, Container, Header, Segment, Image } from 'semantic-ui-react'; +import { useStore } from '../../app/stores/store'; +import LoginForm from '../users/LoginForm'; +import RegisterForm from '../users/RegisterForm'; -export default function HomePage() { +export default observer(function HomePage() { + const { userStore, modalStore } = useStore(); return ( @@ -14,11 +19,39 @@ export default function HomePage() { /> Activity Hub -
- + {!userStore.isLoggedIn && ( +
+ )} + {userStore.isLoggedIn ? ( + <> +
+ + + ) : ( + <> + + + + )} ); -} +}); diff --git a/client-app/src/features/users/LoginForm.tsx b/client-app/src/features/users/LoginForm.tsx new file mode 100644 index 0000000..9a7bff2 --- /dev/null +++ b/client-app/src/features/users/LoginForm.tsx @@ -0,0 +1,50 @@ +import { ErrorMessage, Form, Formik } from 'formik'; +import { observer } from 'mobx-react-lite'; +import { Button, Header, Label } from 'semantic-ui-react'; +import { useStore } from '../../app/stores/store'; +import TextInput from '../../app/common/form/TextInput'; + +export default observer(function LoginForm() { + const { userStore } = useStore(); + return ( + + userStore + .login(values) + .catch(() => setErrors({ error: 'Invalid email or password' })) + } + > + {({ handleSubmit, isSubmitting, errors }) => ( +
+
+ + + ( +