diff --git a/Bimix.API/Bimix.API.csproj b/Bimix.API/Bimix.API.csproj
index b9f2005..e4972ba 100644
--- a/Bimix.API/Bimix.API.csproj
+++ b/Bimix.API/Bimix.API.csproj
@@ -7,16 +7,18 @@
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
+
-
diff --git a/Bimix.API/Controllers/AuthController.cs b/Bimix.API/Controllers/AuthController.cs
new file mode 100644
index 0000000..7ed1397
--- /dev/null
+++ b/Bimix.API/Controllers/AuthController.cs
@@ -0,0 +1,96 @@
+using System.Security.Claims;
+using Bimix.API.Services;
+using Bimix.Application.DTOModels;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bimix.API.Controllers;
+
+public class AuthController(
+ GoogleAuthService googleAuthService,
+ JwtTokenService jwtTokenService,
+ ILogger logger)
+ : ControllerBase
+{
+ [HttpPost("google")]
+ public async Task GoogleAuth([FromBody] GoogleAuthRequest request)
+ {
+ try
+ {
+ if (string.IsNullOrEmpty(request.IdToken))
+ {
+ return BadRequest(new GoogleAuthResponse
+ {
+ Success = false,
+ Error = "IdToken is required"
+ });
+ }
+
+ var (isValid, user, error) = await googleAuthService.ValidateGoogleTokenAsync(request.IdToken);
+
+ if (!isValid || user == null)
+ {
+ var statusCode = error switch
+ {
+ "User not authorized to access this application" => 403,
+ "User account is not active" => 403,
+ "Invalid Google token" => 401,
+ _ => 401
+ };
+
+ return StatusCode(statusCode, new GoogleAuthResponse
+ {
+ Success = false,
+ Error = error ?? "Authentication failed"
+ });
+ }
+
+ var jwt = jwtTokenService.GenerateToken(user);
+
+ return Ok(new GoogleAuthResponse
+ {
+ Success = true,
+ Token = jwt,
+ User = new UserDto
+ {
+ Id = user.Id,
+ Email = user.Email,
+ FullName = user.FullName,
+ IsActive = user.IsActive,
+ LastLoginAt = user.LastLoginAt
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error during Google authentication");
+ return StatusCode(500, new GoogleAuthResponse
+ {
+ Success = false,
+ Error = "Internal server error"
+ });
+ }
+ }
+
+ [HttpGet("me")]
+ [Authorize]
+ public IActionResult GetCurrentUser()
+ {
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier);
+ var emailClaim = User.FindFirst(ClaimTypes.Email);
+ var nameClaim = User.FindFirst(ClaimTypes.Name);
+
+ if (userIdClaim == null || emailClaim == null || nameClaim == null)
+ {
+ return Unauthorized();
+ }
+
+ return Ok(new UserDto
+ {
+ Id = Guid.Parse(userIdClaim.Value),
+ Email = emailClaim.Value,
+ FullName = nameClaim.Value,
+ IsActive = true,
+ });
+ }
+}
\ No newline at end of file
diff --git a/Bimix.API/Program.cs b/Bimix.API/Program.cs
index 72aa51f..c614b8e 100644
--- a/Bimix.API/Program.cs
+++ b/Bimix.API/Program.cs
@@ -1,6 +1,11 @@
+using System.Text;
+using Bimix.API.Services;
using Bimix.Infrastructure.Data;
using Bimix.Infrastructure.Sync;
using Microsoft.EntityFrameworkCore;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+
var builder = WebApplication.CreateBuilder(args);
@@ -13,6 +18,43 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
+// Start auth section
+var jwtSettings = builder.Configuration.GetSection("JwtSettings");
+var secretKey = jwtSettings["SecretKey"];
+var issuer = jwtSettings["Issuer"];
+var audience = jwtSettings["Audience"];
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ ValidIssuer = issuer,
+ ValidAudience = audience,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
+ ClockSkew = TimeSpan.Zero,
+ };
+ });
+builder.Services.AddAuthentication();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy("AllowAll", policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+});
+// End auth section
+
var app = builder.Build();
if (app.Environment.IsDevelopment())
@@ -22,6 +64,8 @@ if (app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
+app.UseCors("AllowAll");
+app.UseAuthorization();
app.UseAuthorization();
app.MapControllers();
app.Run();
\ No newline at end of file
diff --git a/Bimix.API/Services/GoogleAuthService.cs b/Bimix.API/Services/GoogleAuthService.cs
new file mode 100644
index 0000000..8ca694d
--- /dev/null
+++ b/Bimix.API/Services/GoogleAuthService.cs
@@ -0,0 +1,72 @@
+using Bimix.Domain.Entities;
+using Bimix.Infrastructure.Data;
+using Google.Apis.Auth;
+using Microsoft.EntityFrameworkCore;
+
+namespace Bimix.API.Services;
+
+public class GoogleAuthService(BimixDbContext context, IConfiguration configuration, ILogger logger)
+{
+ private readonly BimixDbContext _context = context;
+ private readonly IConfiguration _configuration = configuration;
+ private readonly ILogger _logger = logger;
+
+ public async Task<(bool IsValid, User? user, string? error)> ValidateGoogleTokenAsync(string idToken)
+ {
+ try
+ {
+ var clientId = _configuration["GoogleAuth:ClientId"];
+ if (string.IsNullOrEmpty(clientId))
+ {
+ _logger.LogError("Google Auth Client Id is not configured");
+ return (false, null, "Google Auth Client Id is not configured");
+ }
+
+ var payload = await GoogleJsonWebSignature.ValidateAsync(idToken,
+ new GoogleJsonWebSignature.ValidationSettings
+ {
+ Audience = new[] { clientId }
+ });
+
+ _logger.LogInformation("Google token validated for user: {Email}", payload.Email);
+
+ var user = await _context.Users
+ .FirstOrDefaultAsync(x => x.GoogleId == payload.Subject || x.Email == payload.Email);
+
+ if (user == null)
+ {
+ _logger.LogError("User not found in Bimix database: {Email}", payload.Email);
+ return (false, null, "User not found in Bimix database");
+ }
+
+ if (!user.IsActive)
+ {
+ _logger.LogError("User is not active: {Email}", payload.Email);
+ return (false, null, "User is not active");
+ }
+
+ user.LastLoginAt = DateTime.UtcNow;
+ user.FullName = payload.Name;
+
+ if (user.GoogleId != payload.Subject)
+ {
+ user.GoogleId = payload.Subject;
+ }
+
+ await _context.SaveChangesAsync();
+
+ _logger.LogInformation("User logged in: {Email}", payload.Email);
+
+ return (true, user, null);
+ }
+ catch (InvalidJwtException ex)
+ {
+ _logger.LogError(ex, "Invalid JWT token");
+ return (false, null, "Invalid JWT token");
+ } catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error validating Google token");
+ return (false, null, "Error validating Google token");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bimix.API/Services/JwtTokenService.cs b/Bimix.API/Services/JwtTokenService.cs
new file mode 100644
index 0000000..f37cfda
--- /dev/null
+++ b/Bimix.API/Services/JwtTokenService.cs
@@ -0,0 +1,87 @@
+using Microsoft.IdentityModel.Tokens;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Bimix.Domain.Entities;
+
+
+namespace Bimix.API.Services;
+
+public class JwtTokenService(IConfiguration configuration, ILogger logger)
+{
+ private readonly IConfiguration _configuration = configuration;
+ private readonly ILogger _logger = logger;
+
+ public string GenerateToken(User user)
+ {
+ var jwtSettings = _configuration.GetSection("JwtSettings");
+ var securityKey = jwtSettings["SecurityKey"];
+ var issuer = jwtSettings["Issuer"];
+ var audience = jwtSettings["Audience"];
+ var expiryDays = int.Parse(jwtSettings["ExpiryDays"] ?? "7");
+
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
+ new Claim(ClaimTypes.Email, user.Email),
+ new Claim(ClaimTypes.Name, user.FullName),
+ new Claim("google_id", user.GoogleId),
+ new Claim("is_active", user.IsActive.ToString()),
+ new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
+ new Claim(JwtRegisteredClaimNames.Iat, new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString(),
+ ClaimValueTypes.Integer64)
+ };
+
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(securityKey));
+ var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var token = new JwtSecurityToken(
+ issuer: issuer,
+ audience: audience,
+ claims: claims,
+ expires: DateTime.UtcNow.AddDays(expiryDays),
+ signingCredentials: creds
+ );
+
+ var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
+
+ _logger.LogInformation("Generated JWT token for user: {Email}", user.Email);
+
+ return tokenString;
+ }
+
+ public ClaimsPrincipal? ValidateToken(string token)
+ {
+ try
+ {
+ var jwtSettings = _configuration.GetSection("JwtSettings");
+ var secretKey = jwtSettings["SecretKey"];
+ var issuer = jwtSettings["Issuer"];
+ var audience = jwtSettings["Audience"];
+
+ var tokenHandler = new JwtSecurityTokenHandler();
+ var key = Encoding.UTF8.GetBytes(secretKey);
+
+ var validationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ ValidIssuer = issuer,
+ ValidAudience = audience,
+ IssuerSigningKey = new SymmetricSecurityKey(key),
+ ClockSkew = TimeSpan.Zero
+ };
+
+ var principal = tokenHandler.ValidateToken(token, validationParameters, out _);
+ return principal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error validating JWT token");
+ return null;
+ }
+ }
+}
+
diff --git a/Bimix.API/appsettings.Development.json b/Bimix.API/appsettings.Development.json
index 9165130..a13a784 100644
Binary files a/Bimix.API/appsettings.Development.json and b/Bimix.API/appsettings.Development.json differ
diff --git a/Bimix.Application/DTOModels/AuthDto.cs b/Bimix.Application/DTOModels/AuthDto.cs
new file mode 100644
index 0000000..a150a9c
--- /dev/null
+++ b/Bimix.Application/DTOModels/AuthDto.cs
@@ -0,0 +1,22 @@
+namespace Bimix.Application.DTOModels;
+
+public class GoogleAuthRequest
+{
+ public string? IdToken { get; set; }
+}
+
+public class GoogleAuthResponse
+{
+ public bool Success { get; set; }
+ public string? Token { get; set; }
+ public UserDto? User { get; set; }
+ public string? Error { get; set; }
+}
+
+public class UserDto {
+ public Guid Id { get; set; }
+ public string Email { get; set; } = default!;
+ public string FullName { get; set; } = default!;
+ public bool IsActive { get; set; }
+ public DateTime? LastLoginAt { get; set; }
+}
\ No newline at end of file
diff --git a/Bimix.Domain/Entities/User.cs b/Bimix.Domain/Entities/User.cs
new file mode 100644
index 0000000..490a4a0
--- /dev/null
+++ b/Bimix.Domain/Entities/User.cs
@@ -0,0 +1,10 @@
+namespace Bimix.Domain.Entities;
+
+public class User : BaseEntity
+{
+ public string GoogleId { get; set; } = default!;
+ public string Email { get; set; } = default!;
+ public string FullName { get; set; } = default!;
+ public bool IsActive { get; set; } = false;
+ public DateTime? LastLoginAt { get; set; }
+}
\ No newline at end of file
diff --git a/Bimix.Infrastructure/Data/BimixDbContext.cs b/Bimix.Infrastructure/Data/BimixDbContext.cs
index 3388c1e..96875cb 100644
--- a/Bimix.Infrastructure/Data/BimixDbContext.cs
+++ b/Bimix.Infrastructure/Data/BimixDbContext.cs
@@ -7,17 +7,84 @@ public class BimixDbContext(DbContextOptions options) : DbContex
{
public DbSet Products { get; set; }
public DbSet SyncStates { get; set; }
+ public DbSet Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
+ // Product properties
modelBuilder.Entity().HasKey(x => x.Id);
modelBuilder.Entity().Property(x => x.Name).IsRequired().HasMaxLength(512);
modelBuilder.Entity().Property(x => x.Code).IsRequired().HasMaxLength(40);
modelBuilder.Entity().Property(x => x.Ean).IsRequired().HasMaxLength(50);
modelBuilder.Entity().Property(x => x.StockAddresses).IsRequired().HasMaxLength(512);
+ // SyncState properties
modelBuilder.Entity().HasKey((x => x.Entity));
+
+ // User properties
+ modelBuilder.Entity().HasKey(x => x.Id);
+ modelBuilder.Entity().Property(x => x.GoogleId).IsRequired().HasMaxLength(100);
+ modelBuilder.Entity().Property(x => x.Email).IsRequired().HasMaxLength(255);
+ modelBuilder.Entity().Property(x => x.FullName).IsRequired().HasMaxLength(255);
+ modelBuilder.Entity().Property(x => x.IsActive).IsRequired().HasDefaultValue(false);
+ modelBuilder.Entity().Property(x => x.LastLoginAt).IsRequired(false);
+
+ // User indexes
+ modelBuilder.Entity().HasIndex(x => x.GoogleId).IsUnique().HasDatabaseName("IX_Users_GoogleId");
+ modelBuilder.Entity().HasIndex(x => x.Email).IsUnique().HasDatabaseName("IX_Users_Email");
+
+ // Configure defaults for all CreatedAt and UpdatedAt in entities
+ ConfigureBaseEntity(modelBuilder);
+ }
+
+ private void ConfigureBaseEntity(ModelBuilder modelBuilder)
+ {
+ foreach (var entityType in modelBuilder.Model.GetEntityTypes())
+ {
+ if (typeof(BaseEntity).IsAssignableFrom(entityType.ClrType))
+ {
+ modelBuilder.Entity(entityType.ClrType)
+ .Property(nameof(BaseEntity.CreatedAt))
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ modelBuilder.Entity(entityType.ClrType)
+ .Property(nameof(BaseEntity.UpdatedAt))
+ .HasDefaultValueSql("GETUTCDATE()");
+ }
+ }
+ }
+
+ public override int SaveChanges()
+ {
+ UpdateTimestamps();
+ return base.SaveChanges();
+ }
+
+ public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ UpdateTimestamps();
+ return base.SaveChangesAsync(cancellationToken);
+ }
+
+ private void UpdateTimestamps()
+ {
+ var entities = ChangeTracker.Entries();
+
+ foreach (var entity in entities)
+ {
+ if (entity.State == EntityState.Added)
+ {
+ entity.Entity.CreatedAt = DateTime.UtcNow;
+ entity.Entity.UpdatedAt = DateTime.UtcNow;
+ break;
+ }
+ else if (entity.State == EntityState.Modified)
+ {
+ entity.Entity.UpdatedAt = DateTime.UtcNow;
+ break;
+ }
+ }
}
}
\ No newline at end of file
diff --git a/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs
new file mode 100644
index 0000000..56377d1
--- /dev/null
+++ b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.Designer.cs
@@ -0,0 +1,136 @@
+//
+using System;
+using Bimix.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Bimix.Infrastructure.Migrations
+{
+ [DbContext(typeof(BimixDbContext))]
+ [Migration("20250718162313_AddUsersTable")]
+ partial class AddUsersTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.17")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Bimix.Domain.Entities.Product", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.Property("Ean")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("StockAddresses")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.HasKey("Id");
+
+ b.ToTable("Products");
+ });
+
+ modelBuilder.Entity("Bimix.Domain.Entities.SyncState", b =>
+ {
+ b.Property("Entity")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LastSynced")
+ .HasColumnType("bigint");
+
+ b.HasKey("Entity");
+
+ b.ToTable("SyncStates");
+ });
+
+ modelBuilder.Entity("Bimix.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("GoogleId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("LastLoginAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("IX_Users_Email");
+
+ b.HasIndex("GoogleId")
+ .IsUnique()
+ .HasDatabaseName("IX_Users_GoogleId");
+
+ b.ToTable("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs
new file mode 100644
index 0000000..95a9473
--- /dev/null
+++ b/Bimix.Infrastructure/Migrations/20250718162313_AddUsersTable.cs
@@ -0,0 +1,88 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Bimix.Infrastructure.Migrations
+{
+ ///
+ public partial class AddUsersTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "UpdatedAt",
+ table: "Products",
+ type: "datetime2",
+ nullable: false,
+ defaultValueSql: "GETUTCDATE()",
+ oldClrType: typeof(DateTime),
+ oldType: "datetime2");
+
+ migrationBuilder.AlterColumn(
+ name: "CreatedAt",
+ table: "Products",
+ type: "datetime2",
+ nullable: false,
+ defaultValueSql: "GETUTCDATE()",
+ oldClrType: typeof(DateTime),
+ oldType: "datetime2");
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ GoogleId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ Email = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false),
+ FullName = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false),
+ IsActive = table.Column(type: "bit", nullable: false, defaultValue: false),
+ LastLoginAt = table.Column(type: "datetime2", nullable: true),
+ CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
+ UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_Email",
+ table: "Users",
+ column: "Email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Users_GoogleId",
+ table: "Users",
+ column: "GoogleId",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Users");
+
+ migrationBuilder.AlterColumn(
+ name: "UpdatedAt",
+ table: "Products",
+ type: "datetime2",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "datetime2",
+ oldDefaultValueSql: "GETUTCDATE()");
+
+ migrationBuilder.AlterColumn(
+ name: "CreatedAt",
+ table: "Products",
+ type: "datetime2",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "datetime2",
+ oldDefaultValueSql: "GETUTCDATE()");
+ }
+ }
+}
diff --git a/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs b/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs
index d4a21e3..02130e5 100644
--- a/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs
+++ b/Bimix.Infrastructure/Migrations/BimixDbContextModelSnapshot.cs
@@ -34,7 +34,9 @@ namespace Bimix.Infrastructure.Migrations
.HasColumnType("nvarchar(40)");
b.Property("CreatedAt")
- .HasColumnType("datetime2");
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
b.Property("Ean")
.IsRequired()
@@ -52,7 +54,9 @@ namespace Bimix.Infrastructure.Migrations
.HasColumnType("nvarchar(512)");
b.Property("UpdatedAt")
- .HasColumnType("datetime2");
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
b.HasKey("Id");
@@ -71,6 +75,58 @@ namespace Bimix.Infrastructure.Migrations
b.ToTable("SyncStates");
});
+
+ modelBuilder.Entity("Bimix.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("GoogleId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(false);
+
+ b.Property("LastLoginAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("GETUTCDATE()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("IX_Users_Email");
+
+ b.HasIndex("GoogleId")
+ .IsUnique()
+ .HasDatabaseName("IX_Users_GoogleId");
+
+ b.ToTable("Users");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/Bimix.UI.Shared/Bimix.UI.Shared.csproj b/Bimix.UI.Shared/Bimix.UI.Shared.csproj
index 672ca7e..c7a5aec 100644
--- a/Bimix.UI.Shared/Bimix.UI.Shared.csproj
+++ b/Bimix.UI.Shared/Bimix.UI.Shared.csproj
@@ -24,11 +24,7 @@
-
-
-
-
-
+
diff --git a/Bimix.UI.Shared/Components/AuthGuard.razor b/Bimix.UI.Shared/Components/AuthGuard.razor
new file mode 100644
index 0000000..225a102
--- /dev/null
+++ b/Bimix.UI.Shared/Components/AuthGuard.razor
@@ -0,0 +1,43 @@
+@using Bimix.UI.Shared.Services
+@inject AuthService AuthService
+@inject NavigationManager Navigation
+
+@if (_isLoading)
+{
+
+
+
+}
+else if (_isAuthenticated)
+{
+ @ChildContent
+}
+
+@code {
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ private bool _isLoading = true;
+ private bool _isAuthenticated = false;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ Console.WriteLine("AuthGuard: Checking authentication...");
+
+ // ZAWSZE sprawdź localStorage przy inicjalizacji
+ _isAuthenticated = await AuthService.CheckAuthenticationAsync();
+ _isLoading = false;
+
+ Console.WriteLine($"AuthGuard: isAuthenticated={_isAuthenticated}");
+
+ if (!_isAuthenticated)
+ {
+ Console.WriteLine("AuthGuard: Redirecting to /login");
+ Navigation.NavigateTo("/login", replace: true);
+ }
+
+ StateHasChanged();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bimix.UI.Shared/Components/LoginCard.razor b/Bimix.UI.Shared/Components/LoginCard.razor
new file mode 100644
index 0000000..db146c1
--- /dev/null
+++ b/Bimix.UI.Shared/Components/LoginCard.razor
@@ -0,0 +1,121 @@
+@using Microsoft.Extensions.Configuration
+@using Bimix.UI.Shared.Services
+@inject IJSRuntime JS
+@inject IConfiguration Configuration
+@inject AuthService AuthService
+@inject NavigationManager NavigationManager
+
+
+
+ Witaj w Bimix
+
+ Zaloguj się używając konta Google
+
+
+
+ @if (_isLoading)
+ {
+
+ }
+ else
+ {
+ Zaloguj z Google
+ }
+
+
+ @if (!string.IsNullOrEmpty(_errorMessage))
+ {
+
+ @_errorMessage
+
+ }
+
+
+
+@code {
+ private bool _isLoading = false;
+ private string _errorMessage = string.Empty;
+ private static LoginCard? _instance;
+
+ protected override void OnInitialized()
+ {
+ _instance = this;
+ }
+
+ private async Task HandleGoogleSignIn()
+ {
+ try
+ {
+ _isLoading = true;
+ _errorMessage = string.Empty;
+ StateHasChanged();
+
+ var clientId = Configuration["GoogleAuth:ClientId"];
+
+ if (string.IsNullOrEmpty(clientId))
+ {
+ throw new Exception("Google ClientId is not configured.");
+ }
+
+
+ await JS.InvokeVoidAsync("initGoogleSignIn", clientId);
+ }
+ catch (Exception ex)
+ {
+ _errorMessage = "Błąd podczas logownia. Spróbuj ponownie";
+ _isLoading = false;
+ StateHasChanged();
+ }
+ }
+
+ [JSInvokable]
+ public static async Task OnGoogleSignInSuccess(string idToken)
+ {
+ Console.WriteLine($"Google ID Token: {idToken}");
+
+ if (_instance != null)
+ {
+ await _instance.AuthService.SetAuthenticationAsync(idToken);
+
+ _instance._isLoading = false;
+ _instance._errorMessage = string.Empty;
+
+ _instance.NavigationManager.NavigateTo("/dashboard", replace:true);
+
+ await _instance.InvokeAsync(() => _instance.StateHasChanged());
+ }
+ }
+
+ [JSInvokable]
+ public static async Task OnGoggleSignInError(string error)
+ {
+ Console.WriteLine($"Google SignIn Error: {error}");
+
+ if (_instance != null)
+ {
+ _instance._isLoading = false;
+ _instance._errorMessage = "Błąd logowanie Google. Spróbuj ponownie";
+ await _instance.InvokeAsync(() => _instance.StateHasChanged());
+ }
+ }
+
+}
+
+
\ No newline at end of file
diff --git a/Bimix.UI.Shared/EmptyLayout.razor b/Bimix.UI.Shared/EmptyLayout.razor
new file mode 100644
index 0000000..2416b89
--- /dev/null
+++ b/Bimix.UI.Shared/EmptyLayout.razor
@@ -0,0 +1,8 @@
+@inherits LayoutComponentBase
+
+
+
+
+
+
+@Body
\ No newline at end of file
diff --git a/Bimix.UI.Shared/MainLayout.razor b/Bimix.UI.Shared/MainLayout.razor
index ab0bade..0e55d0e 100644
--- a/Bimix.UI.Shared/MainLayout.razor
+++ b/Bimix.UI.Shared/MainLayout.razor
@@ -1,41 +1,43 @@
@using MudBlazor
@inherits LayoutComponentBase
-
-
-
+
+
+
+
-
-
-
-
-
- Bimix
-
+
+
+
+
+
+ Bimix
+
-
-
- Dashboard
- Products
-
-
+
+
+ Dashboard
+ Products
+
+
-
-
- @Body
-
-
-
+
+
+ @Body
+
+
+
+
@code {
@@ -60,7 +62,8 @@
_drawerVariant = DrawerVariant.Persistent;
_drawerOpen = true;
}
-
+
StateHasChanged();
}
+
}
\ No newline at end of file
diff --git a/Bimix.UI.Shared/Pages/LoginPage.razor b/Bimix.UI.Shared/Pages/LoginPage.razor
new file mode 100644
index 0000000..ee1255e
--- /dev/null
+++ b/Bimix.UI.Shared/Pages/LoginPage.razor
@@ -0,0 +1,46 @@
+@page "/login"
+@layout EmptyLayout
+
+
+
+
\ No newline at end of file
diff --git a/Bimix.UI.Shared/Services/AuthService.cs b/Bimix.UI.Shared/Services/AuthService.cs
new file mode 100644
index 0000000..cb99e6b
--- /dev/null
+++ b/Bimix.UI.Shared/Services/AuthService.cs
@@ -0,0 +1,86 @@
+using Microsoft.JSInterop;
+
+namespace Bimix.UI.Shared.Services;
+
+public class AuthService
+{
+ private readonly IJSRuntime _jsRuntime;
+ private bool? _isAuthenticated;
+
+ public event Action? AuthenticationStateChanged;
+
+ public AuthService(IJSRuntime jsRuntime)
+ {
+ _jsRuntime = jsRuntime;
+ }
+
+ public bool IsAuthenticated => _isAuthenticated ?? false;
+
+ public async Task CheckAuthenticationAsync()
+ {
+ try
+ {
+ var token = await _jsRuntime.InvokeAsync("localStorage.getItem", "google_token");
+ _isAuthenticated = !string.IsNullOrEmpty(token);
+
+ Console.WriteLine($"AuthService.CheckAuthentication: token={(!string.IsNullOrEmpty(token) ? "EXISTS" : "NULL")}, isAuth={_isAuthenticated}");
+
+ return _isAuthenticated.Value;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"AuthService.CheckAuthentication ERROR: {ex.Message}");
+ _isAuthenticated = false;
+ return false;
+ }
+ }
+
+ public async Task SetAuthenticationAsync(string token)
+ {
+ try
+ {
+ await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "google_token", token);
+ _isAuthenticated = true;
+ Console.WriteLine($"AuthService.SetAuthentication: token saved, isAuth={_isAuthenticated}");
+ AuthenticationStateChanged?.Invoke(true);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"AuthService.SetAuthentication ERROR: {ex.Message}");
+ }
+ }
+
+ public async Task ClearAuthenticationAsync()
+ {
+ try
+ {
+ await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "google_token");
+ _isAuthenticated = false;
+ Console.WriteLine($"AuthService.ClearAuthentication: token removed");
+ AuthenticationStateChanged?.Invoke(false);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"AuthService.ClearAuthentication ERROR: {ex.Message}");
+ }
+ }
+
+ public async Task GetTokenAsync()
+ {
+ if (_isAuthenticated != true)
+ {
+ await CheckAuthenticationAsync();
+ }
+
+ if (_isAuthenticated != true) return null;
+
+ try
+ {
+ return await _jsRuntime.InvokeAsync("localStorage.getItem", "google_token");
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bimix.UI.Shared/Services/GoogleAuthConfig.cs b/Bimix.UI.Shared/Services/GoogleAuthConfig.cs
new file mode 100644
index 0000000..94e258b
--- /dev/null
+++ b/Bimix.UI.Shared/Services/GoogleAuthConfig.cs
@@ -0,0 +1,7 @@
+namespace Bimix.UI.Shared.Services;
+
+// TODO it's a good place for this file?
+public class GoogleAuthConfig
+{
+ public string ClientId { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/Bimix.UI.Shared/wwwroot/images/login-background.jpg b/Bimix.UI.Shared/wwwroot/images/login-background.jpg
new file mode 100644
index 0000000..419e8a3
Binary files /dev/null and b/Bimix.UI.Shared/wwwroot/images/login-background.jpg differ
diff --git a/Bimix.UI.Shared/wwwroot/js/auth.js b/Bimix.UI.Shared/wwwroot/js/auth.js
new file mode 100644
index 0000000..3ed0254
--- /dev/null
+++ b/Bimix.UI.Shared/wwwroot/js/auth.js
@@ -0,0 +1,35 @@
+window.initGoogleSignIn = async function(clientId) {
+ try {
+ if (!clientId) {
+ throw new Error('ClientId is required');
+ }
+
+ // Inicjalizacja Google Sign-In z dynamicznym ClientId
+ google.accounts.id.initialize({
+ client_id: clientId,
+ callback: handleGoogleResponse,
+ auto_select: false,
+ cancel_on_tap_outside: true
+ });
+
+ // Wyświetl popup logowania
+ google.accounts.id.prompt((notification) => {
+ if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
+ console.log('Google Sign-In popup not displayed');
+ }
+ });
+
+ } catch (error) {
+ console.error('Google Sign-In initialization error:', error);
+ DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', error.message);
+ }
+};
+
+function handleGoogleResponse(response) {
+ if (response.credential) {
+ // Token otrzymany - wyślij do Blazor
+ DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInSuccess', response.credential);
+ } else {
+ DotNet.invokeMethodAsync('Bimix.UI.Shared', 'OnGoogleSignInError', 'No credential received');
+ }
+}
diff --git a/Bimix.UI.Web/Components/App.razor b/Bimix.UI.Web/Components/App.razor
index f7d4146..c0ae221 100644
--- a/Bimix.UI.Web/Components/App.razor
+++ b/Bimix.UI.Web/Components/App.razor
@@ -30,6 +30,9 @@
+
+
+