diff --git a/DougShared/Dtos/AuthResponseDto.cs b/DougShared/Dtos/AuthResponseDto.cs new file mode 100644 index 0000000..baeaa6c --- /dev/null +++ b/DougShared/Dtos/AuthResponseDto.cs @@ -0,0 +1,3 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record AuthResponseDto(LoggedInUser User, string Token); diff --git a/DougShared/Dtos/ChangePasswordDto.cs b/DougShared/Dtos/ChangePasswordDto.cs new file mode 100644 index 0000000..a5c9c35 --- /dev/null +++ b/DougShared/Dtos/ChangePasswordDto.cs @@ -0,0 +1,2 @@ +namespace DoughnutMaui.Shared.Dtos; +public record ChangePasswordDto(string OldPassword, string NewPassword); diff --git a/DougShared/Dtos/DougDto.cs b/DougShared/Dtos/DougDto.cs new file mode 100644 index 0000000..5b88232 --- /dev/null +++ b/DougShared/Dtos/DougDto.cs @@ -0,0 +1,5 @@ +namespace DoughnutMaui.Shared.Dtos; +public record struct DougOptionDto(string Flavor, string Topping); + +public record DougDto(int Id, string Name, string Image, double Price, DougOptionDto[] Options); + diff --git a/DougShared/Dtos/LoggedInUser.cs b/DougShared/Dtos/LoggedInUser.cs new file mode 100644 index 0000000..022f447 --- /dev/null +++ b/DougShared/Dtos/LoggedInUser.cs @@ -0,0 +1,3 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record LoggedInUser(Guid Id, string Name, string Email, string Address); diff --git a/DougShared/Dtos/OrderDto.cs b/DougShared/Dtos/OrderDto.cs new file mode 100644 index 0000000..e275251 --- /dev/null +++ b/DougShared/Dtos/OrderDto.cs @@ -0,0 +1,15 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record OrderItemDto(long Id, int IcecreamId, string Name, int Quantity, double Price, string Flavor, string Topping) +{ + public double TotalPrice => Quantity * Price; +} +public record OrderDto(long Id, DateTime OrderdAt, double TotalPrice, int ItemsCount = 0) +{ + public string ItemCountDisplay => ItemsCount + (ItemsCount > 1 ? " Items" : " Item"); +} + +public record OrderPlaceDto(OrderDto Order, OrderItemDto[] Items); + + + diff --git a/DougShared/Dtos/ResultDto.cs b/DougShared/Dtos/ResultDto.cs new file mode 100644 index 0000000..db00568 --- /dev/null +++ b/DougShared/Dtos/ResultDto.cs @@ -0,0 +1,7 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record ResultDto(bool IsSuccess, string? ErrorMessage) +{ + public static ResultDto Success() => new(true, null); + public static ResultDto Failure(string? errorMessage) => new(false, errorMessage); +} diff --git a/DougShared/Dtos/ResultWithDataDto.cs b/DougShared/Dtos/ResultWithDataDto.cs new file mode 100644 index 0000000..2e903f0 --- /dev/null +++ b/DougShared/Dtos/ResultWithDataDto.cs @@ -0,0 +1,7 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record ResultWithDataDto(bool IsSuccess, TData Data, string? ErrorMessage) +{ + public static ResultWithDataDto Success(TData data) => new(true, data, null); + public static ResultWithDataDto Failure(string? errorMessage) => new(false, default, errorMessage); +} \ No newline at end of file diff --git a/DougShared/Dtos/SigninRequestDto.cs b/DougShared/Dtos/SigninRequestDto.cs new file mode 100644 index 0000000..30ada65 --- /dev/null +++ b/DougShared/Dtos/SigninRequestDto.cs @@ -0,0 +1,3 @@ +namespace DoughnutMaui.Shared.Dtos; + +public record SigninRequestDto(string Email, string Password); diff --git a/DougShared/Dtos/SignupRequestDto.cs b/DougShared/Dtos/SignupRequestDto.cs new file mode 100644 index 0000000..a4db9cf --- /dev/null +++ b/DougShared/Dtos/SignupRequestDto.cs @@ -0,0 +1,2 @@ +namespace DoughnutMaui.Shared.Dtos; +public record SignupRequestDto(string Name, string Email, string Password, string Address); diff --git a/DoughnutApi/Endpoints/Endpoints.cs b/DoughnutApi/Endpoints/Endpoints.cs new file mode 100644 index 0000000..e831c88 --- /dev/null +++ b/DoughnutApi/Endpoints/Endpoints.cs @@ -0,0 +1,48 @@ +using DoughnutMaui.Api.Services; +using DoughnutMaui.Shared.Dtos; +using System.Security.Claims; + +namespace DoughnutMaui.Api.Endpoints; + +public static class Endpoints +{ + private static Guid GetUserId(this ClaimsPrincipal principal) => + Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public static IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("/api/auth/signup", + async (SignupRequestDto dto, AuthService authService) => + TypedResults.Ok(await authService.SignupAsync(dto))); + + app.MapPost("/api/auth/signin", + async (SigninRequestDto dto, AuthService authService) => + TypedResults.Ok(await authService.SigninAsync(dto))); + + app.MapPost("/api/auth/change-password", + async (ChangePasswordDto dto, ClaimsPrincipal principal, AuthService authService) => + TypedResults.Ok(await authService.ChangePasswordAsync(dto, principal.GetUserId())) + ) + .RequireAuthorization(); + + app.MapGet("/api/icecreams", + async(DougService icecreamService) => + TypedResults.Ok(await icecreamService.GetIcecreamsAsync())); + + var orderGroup = app.MapGroup("/api/orders").RequireAuthorization(); + + orderGroup.MapPost("/place-order", + async (OrderPlaceDto dto, ClaimsPrincipal principal, OrderService orderService) => + await orderService.PlaceOrderAsync(dto, principal.GetUserId())); + + orderGroup.MapGet("", + async (ClaimsPrincipal principal, OrderService orderService) => + TypedResults.Ok(await orderService.GetUserOrdersAsync(principal.GetUserId()))); + + orderGroup.MapGet("/{orderId:long}/items", + async (long orderId, ClaimsPrincipal principal, OrderService orderService) => + TypedResults.Ok(await orderService.GetUserOrderItemsAsync(orderId, principal.GetUserId()))); + + return app; + } +} diff --git a/DoughnutApi/Services/AuthService.cs b/DoughnutApi/Services/AuthService.cs new file mode 100644 index 0000000..0595c27 --- /dev/null +++ b/DoughnutApi/Services/AuthService.cs @@ -0,0 +1,82 @@ +using DoughnutMaui.Api.Data; +using DoughnutMaui.Api.Data.Entities; +using DoughnutMaui.Shared.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace DoughnutMaui.Api.Services; + +public class AuthService(DataContext context, TokenService tokenService, PasswordService passwordService) +{ + private readonly DataContext _context = context; + private readonly TokenService _tokenService = tokenService; + private readonly PasswordService _passwordService = passwordService; + + public async Task> SignupAsync(SignupRequestDto dto) + { + if(await _context.Users.AsNoTracking().AnyAsync(u=> u.Email == dto.Email)) + { + return ResultWithDataDto.Failure("Email alredy exists"); + } + + var user = new User { + Email = dto.Email, + Address = dto.Address, + Name = dto.Name, + }; + + (user.Salt, user.Hash) = _passwordService.GenerateSaltAndHash(dto.Password); + + try + { + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + return GenerateAuthResponse(user); + } + catch (Exception ex) + { + return ResultWithDataDto.Failure(ex.Message); + } + } + + + public async Task> SigninAsync(SigninRequestDto dto) + { + var dbUser = await _context.Users + .AsNoTracking() + .FirstOrDefaultAsync(u=> u.Email == dto.Email); + if (dbUser is null) + return ResultWithDataDto.Failure("User does not exist"); + + if(!_passwordService.AreEqual(dto.Password, dbUser.Salt, dbUser.Hash)) + return ResultWithDataDto.Failure("Incorrect password"); + + return GenerateAuthResponse(dbUser); + } + + private ResultWithDataDto GenerateAuthResponse(User user) + { + var loggedInUser = new LoggedInUser(user.Id, user.Name, user.Email, user.Address); + var token = _tokenService.GenerateJwt(loggedInUser); + + var authResponse = new AuthResponseDto(loggedInUser, token); + + return ResultWithDataDto.Success(authResponse); + } + + public async Task ChangePasswordAsync(ChangePasswordDto dto, Guid userId) + { + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user is null) + return ResultDto.Failure("Invalid request"); + + if(!_passwordService.AreEqual(dto.OldPassword, user.Salt, user.Hash)) + { + return ResultDto.Failure("Incorrect password"); + } + + (user.Salt, user.Hash) = _passwordService.GenerateSaltAndHash(dto.NewPassword); + + await _context.SaveChangesAsync(); + return ResultDto.Success(); + } +} diff --git a/DoughnutApi/Services/DougService.cs b/DoughnutApi/Services/DougService.cs new file mode 100644 index 0000000..a888951 --- /dev/null +++ b/DoughnutApi/Services/DougService.cs @@ -0,0 +1,25 @@ +using DoughnutMaui.Api.Data; +using DoughnutMaui.Shared.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace DoughnutMaui.Api.Services; + +public class DougService(DataContext context) +{ + private readonly DataContext _context = context; + + public async Task GetIcecreamsAsync() => + await _context.Icecreams.AsNoTracking() + .Select(i=> + new DougDto( + i.Id, + i.Name, + i.Image, + i.Price, + i.Options + .Select(o=> new DougOptionDto(o.Sprinkle, o.Topping)) + .ToArray() + ) + ) + .ToArrayAsync(); +} diff --git a/DoughnutApi/Services/OrderService.cs b/DoughnutApi/Services/OrderService.cs new file mode 100644 index 0000000..c907231 --- /dev/null +++ b/DoughnutApi/Services/OrderService.cs @@ -0,0 +1,62 @@ +using DoughnutMaui.Api.Data; +using DoughnutMaui.Api.Data.Entities; +using DoughnutMaui.Shared.Dtos; +using Microsoft.EntityFrameworkCore; + +namespace DoughnutMaui.Api.Services; + +public class OrderService(DataContext context) +{ + private readonly DataContext _context = context; + + public async Task PlaceOrderAsync(OrderPlaceDto dto, Guid customerId) + { + var customer = await _context.Users.FirstOrDefaultAsync(u => u.Id == customerId); + if (customer is null) + return ResultDto.Failure("Custome does not exist"); + + var orderItems = dto.Items.Select(i => + new OrderItem + { + Flavor = i.Flavor, + DougId = i.IcecreamId, + Name = i.Name, + Price = i.Price, + Quantity = i.Quantity, + Topping = i.Topping, + TotalPrice = i.TotalPrice + }); + + var order = new Order + { + CustomerId = customerId, + CustomerAddress = customer.Address, + CustomerEmail = customer.Email, + CustomerName = customer.Name, + OrderdAt = DateTime.Now, + TotalPrice = orderItems.Sum(o => o.TotalPrice), + Items = orderItems.ToArray() + }; + try + { + await _context.Orders.AddAsync(order); + await _context.SaveChangesAsync(); + return ResultDto.Success(); + } + catch (Exception ex) + { + return ResultDto.Failure(ex.Message); + } + } + + public async Task GetUserOrdersAsync(Guid userId) => + await _context.Orders + .Where(o => o.CustomerId == userId) + .Select(o => new OrderDto(o.Id, o.OrderdAt, o.TotalPrice, o.Items.Count)) + .ToArrayAsync(); + public async Task GetUserOrderItemsAsync(long orderId, Guid userId) => + await _context.OrderItems + .Where(i=> i.OrderId == orderId && i.Order.CustomerId == userId) + .Select(i=> new OrderItemDto(i.Id, i.DougId, i.Name, i.Quantity, i.Price, i.Flavor, i.Topping)) + .ToArrayAsync(); +} diff --git a/DoughnutApi/Services/PasswordService.cs b/DoughnutApi/Services/PasswordService.cs new file mode 100644 index 0000000..bc18b67 --- /dev/null +++ b/DoughnutApi/Services/PasswordService.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DoughnutMaui.Api.Services; + +public class PasswordService +{ + private const int SaltSize = 10; + public (string salt, string hashedPassword) GenerateSaltAndHash(string plainPassword) + { + if(string.IsNullOrWhiteSpace(plainPassword)) + throw new ArgumentNullException(nameof(plainPassword)); + + var buffer = RandomNumberGenerator.GetBytes(SaltSize); + var salt = Convert.ToBase64String(buffer); + + var hashedPassword = GenerateHashedPassword(plainPassword, salt); + + return (salt, hashedPassword); + } + + public bool AreEqual(string plainPassword, string salt, string hashedPassword) + { + var newHashedPassword = GenerateHashedPassword(plainPassword, salt); + return newHashedPassword == hashedPassword; + } + + private static string GenerateHashedPassword(string plainPassword, string salt) { + var bytes = Encoding.UTF8.GetBytes(plainPassword + salt); + var hash = SHA256.HashData(bytes); + + return Convert.ToBase64String(hash); + } +} diff --git a/DoughnutApi/Services/TokenService.cs b/DoughnutApi/Services/TokenService.cs new file mode 100644 index 0000000..17ac18a --- /dev/null +++ b/DoughnutApi/Services/TokenService.cs @@ -0,0 +1,57 @@ +using DoughnutMaui.Shared.Dtos; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace DoughnutMaui.Api.Services; + +public class TokenService(IConfiguration configuration) +{ + private readonly IConfiguration _configuration = configuration; + + public static TokenValidationParameters GetTokenValidationParameters(IConfiguration configuration) => + new() + { + ValidateAudience = false, + ValidateIssuer = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = configuration["Jwt:Issuer"], + IssuerSigningKey = GetSecurityKey(configuration), + }; + + public string GenerateJwt(LoggedInUser user) + { + var securityKey = GetSecurityKey(_configuration); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var issuer = _configuration["Jwt:Issuer"]; + var expireInMinutes = Convert.ToInt32(_configuration["Jwt:ExpireInMinute"]); + + Claim[] claims = [ + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Name), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.StreetAddress, user.Address), + ]; + + var token = new JwtSecurityToken( + issuer: issuer, + audience: "*", + claims: claims, + expires: DateTime.Now.AddMinutes(expireInMinutes), + signingCredentials: credentials); + + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + + return jwt; + } + + private static SymmetricSecurityKey GetSecurityKey(IConfiguration configuration) + { + var secretKey = configuration["Jwt:SecretKey"]; + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey!)); + return securityKey; + } +} diff --git a/DoughnutApp/App.xaml b/DoughnutApp/App.xaml index 091cd40..53b8583 100644 --- a/DoughnutApp/App.xaml +++ b/DoughnutApp/App.xaml @@ -1,14 +1,38 @@ - - + + + + + + diff --git a/DoughnutApp/App.xaml.cs b/DoughnutApp/App.xaml.cs index a6cc832..171c110 100644 --- a/DoughnutApp/App.xaml.cs +++ b/DoughnutApp/App.xaml.cs @@ -1,12 +1,24 @@ -namespace DoughnutApp +using DoughnutMaui.Services; +using DoughnutMaui.ViewModels; + +namespace DoughnutMaui; + +public partial class App : Application { - public partial class App : Application + private readonly CartViewModel _cartViewModel; + + public App(AuthService authService, CartViewModel cartViewModel) { - public App() - { - InitializeComponent(); + InitializeComponent(); + + authService.Initialize(); - MainPage = new AppShell(); - } + MainPage = new AppShell(authService); + _cartViewModel = cartViewModel; + } + + protected override async void OnStart() + { + await _cartViewModel.InitializeCartAsync(); } } diff --git a/DoughnutApp/AppShell.xaml b/DoughnutApp/AppShell.xaml index 3165444..b330a47 100644 --- a/DoughnutApp/AppShell.xaml +++ b/DoughnutApp/AppShell.xaml @@ -1,15 +1,158 @@ + xmlns:local="clr-namespace:DoughnutMaui" + xmlns:pages="clr-namespace:DoughnutMaui.Pages" + xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" + Title="IcecreamMAUI" + Shell.FlyoutBehavior="Flyout"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DoughnutApp/AppShell.xaml.cs b/DoughnutApp/AppShell.xaml.cs index 57606b1..f02ae59 100644 --- a/DoughnutApp/AppShell.xaml.cs +++ b/DoughnutApp/AppShell.xaml.cs @@ -1,10 +1,48 @@ -namespace DoughnutApp +using DoughnutMaui.Pages; +using DoughnutMaui.Services; + +namespace DoughnutMaui; + +public partial class AppShell : Shell { - public partial class AppShell : Shell + public AppShell(AuthService authService) { - public AppShell() + InitializeComponent(); + + //Routing.RegisterRoute(nameof(SigninPage), typeof(SigninPage)); + //Routing.RegisterRoute(nameof(SignupPage), typeof(SignupPage)); + + RegisterRoutes(); + _authService = authService; + } + + private readonly static Type[] _routablePageTypes = + [ + typeof(SigninPage), + typeof(SignupPage), + typeof(MyOrdersPage), + typeof(OrderDetailsPage), + typeof(DetailsPage), + ]; + private readonly AuthService _authService; + + private static void RegisterRoutes() + { + foreach (var pageType in _routablePageTypes) { - InitializeComponent(); - } + Routing.RegisterRoute(pageType.Name, pageType); + } + } + + private async void FlyoutFooter_Tapped(object sender, TappedEventArgs e) + { + await Launcher.OpenAsync("https://www.youtube.com/@abhayprince"); + } + + private async void SignoutMenuItem_Clicked(object sender, EventArgs e) + { + //await Shell.Current.DisplayAlert("Alert", "Signout menu item clicked", "Ok"); + _authService.Signout(); + await Shell.Current.GoToAsync($"//{nameof(OnboardingPage)}"); } } diff --git a/DoughnutApp/Controls/ChangePasswordControl.xaml b/DoughnutApp/Controls/ChangePasswordControl.xaml new file mode 100644 index 0000000..74fb3ac --- /dev/null +++ b/DoughnutApp/Controls/ChangePasswordControl.xaml @@ -0,0 +1,67 @@ + + + + + + + + + + +