diff --git a/Components/Account/Pages/Register.razor b/Components/Account/Pages/Register.razor index 2c4ab33..6881bb0 100644 --- a/Components/Account/Pages/Register.razor +++ b/Components/Account/Pages/Register.razor @@ -82,6 +82,18 @@ Logger.LogInformation("User created a new account with password."); + var int userCount = UserManager.User.Count(); + var string? defaultRole = userCount <= 1 ? AppRoles.Admin : AppRoles.Driver; + var IdentityResult? roleResult = await USerManager.AddToRoleAsync(user, defalutRole) + + if (!roleResult.Succeeded) + { + identityErrors = roleResult.Errors? + return; + } + + Logger.LogInforamtion(message: "Assigned {Role} role to the new account.", defaultRole); + var userId = await UserManager.GetUserIdAsync(user); var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index a55c14c..257f104 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -17,14 +17,38 @@ Home + + + + + + - - + + + + + +<<<<<<< HEAD + + + + + +======= +>>>>>>> main @@ -89,10 +114,10 @@ @* ── Admin-only navigation links ── - checks the current user's roles. + checks the current user's roles. The inner content is only rendered if the user has the Admin role. Non-admin users won't even see these links in the DOM. *@ - + +} + +@code { + private List stockRows = new(); + private string statusMessage = string.Empty; + private string statusCssClass = "alert-info"; + + protected override async Task OnInitializedAsync() + { + await LoadStockRowsAsync(); + } + + private async Task LoadStockRowsAsync() + { + using var context = await DbFactory.CreateDbContextAsync(); + + var productLocations = await context.ProductLocations + .Include(pl => pl.Product) + .ThenInclude(p => p.Category) + .Include(pl => pl.Location) + .OrderBy(pl => pl.Product.Name) + .ThenBy(pl => pl.Location.Name) + .ToListAsync(); + + stockRows = productLocations.Select(pl => new SalesStockRow + { + ProductLocationId = pl.Id, + ProductName = pl.Product.Name, + CategoryName = pl.Product.Category.Name, + LocationName = pl.Location.Name, + Quantity = pl.Quantity + }).ToList(); + } + + private static bool CanSubmit(SalesStockRow row) + { + return row.Quantity > 0 && + row.SaleQuantity > 0 && + row.SaleQuantity <= row.Quantity; + } + + private async Task SubmitSaleAsync(SalesStockRow row) + { + if (!CanSubmit(row)) + { + statusMessage = "Enter a valid quantity that does not exceed current stock."; + statusCssClass = "alert-warning"; + return; + } + + using var context = await DbFactory.CreateDbContextAsync(); + var productLocation = await context.ProductLocations + .Include(pl => pl.Product) + .Include(pl => pl.Location) + .FirstOrDefaultAsync(pl => pl.Id == row.ProductLocationId); + + if (productLocation is null) + { + statusMessage = "The selected stock record no longer exists."; + statusCssClass = "alert-danger"; + await LoadStockRowsAsync(); + return; + } + + if (row.SaleQuantity > productLocation.Quantity) + { + statusMessage = "The sale quantity exceeds the latest stock count. The list has been refreshed."; + statusCssClass = "alert-warning"; + await LoadStockRowsAsync(); + return; + } + + productLocation.Quantity -= row.SaleQuantity; + productLocation.LastUpdated = DateTime.UtcNow; + + await context.SaveChangesAsync(); + + statusMessage = $"Sale submitted for {row.ProductName} at {row.LocationName}."; + statusCssClass = "alert-success"; + await LoadStockRowsAsync(); + } + + private sealed class SalesStockRow + { + public int ProductLocationId { get; set; } + public string ProductName { get; set; } = string.Empty; + public string CategoryName { get; set; } = string.Empty; + public string LocationName { get; set; } = string.Empty; + public int Quantity { get; set; } + public int SaleQuantity { get; set; } = 1; + } +} diff --git a/Components/Routes.razor b/Components/Routes.razor index 3541f33..d6ad619 100644 --- a/Components/Routes.razor +++ b/Components/Routes.razor @@ -1,9 +1,20 @@ @using PuppetFestAPP.Web.Components.Account.Shared + - - + + @if (authContext.User.Identity?.IsAuthenticated == true) + { +
+

Access denied

+

You do not have access to this resource.

+
+ } + else + { + + }
diff --git a/Components/_Imports.razor b/Components/_Imports.razor index 4142cff..00a217c 100644 --- a/Components/_Imports.razor +++ b/Components/_Imports.razor @@ -10,3 +10,6 @@ @using PuppetFestAPP.Web @using PuppetFestAPP.Web.Components @using Microsoft.AspNetCore.Components.QuickGrid + +@using Microsoft.AspNetCore.Authorization +@using PuppetFestAPP.Web.Data \ No newline at end of file diff --git a/Data/AppPolicies.cs b/Data/AppPolicies.cs new file mode 100644 index 0000000..c365cb1 --- /dev/null +++ b/Data/AppPolicies.cs @@ -0,0 +1,15 @@ +// File: Data/AppPolicies.cs +// +// PURPOSE: Centralizes authorization policy names so pages, navigation, +// and Program.cs can all reference the same policy constants. + +namespace PuppetFestAPP.Web.Data; + +public static class AppPolicies +{ + public const string CanViewProducts = "CanViewProducts"; + public const string CanEditProducts = "CanEditProducts"; + public const string CanEnterSales = "CanEnterSales"; + public const string CanManageDeliveryChecks = "CanManageDeliveryChecks"; + public const string CanManageUsers = "CanManageUsers"; +} diff --git a/Data/AppRoles.cs b/Data/AppRoles.cs index c7877f0..f6112c3 100644 --- a/Data/AppRoles.cs +++ b/Data/AppRoles.cs @@ -1,36 +1,79 @@ // File: Data/AppRoles.cs // // PURPOSE: Defines all application roles as compile-time constants. -// Using constants (instead of raw strings) prevents typos and enables -// IntelliSense. Every role check in the app references these constants. +// Using constants instead of raw strings prevents typos and keeps all +// role-based access rules consistent across pages, navigation, and seeding. namespace PuppetFestAPP.Web.Data; /// /// Central registry of all role names used in the application. -/// Roles control what pages a user can access (via the [Authorize] -/// attribute on Razor components) and what navigation links they see -/// (via <AuthorizeView> in NavMenu.razor). +/// +/// Permission hierarchy: +/// Admin > SM > FOH > Driver +/// +/// Driver has the smallest permission set. Any Driver-level capability is +/// also available to FOH, SM, and Admin. /// public static class AppRoles { - /// Full system access: user management, storage admin, all features. + /// Full system access, including user management. public const string Admin = "Admin"; - /// Can view and edit inventory counts, add/remove products. - public const string InventoryManager = "InventoryManager"; + /// Stage manager / operations manager. Can edit operational data but cannot manage users. + public const string SM = "SM"; - /// Can process sales at a merch stand during the festival. - public const string SalesStaff = "SalesStaff"; + /// Front of house. Can view inventory/status data and enter sales. + public const string FOH = "FOH"; - /// - /// Array of all role names. Used by the database seeder to create - /// roles on first run, and by UI components to populate dropdowns. - /// - public static readonly string[] AllRoles = new[] - { + /// Driver / delivery role. Can view stock/location/status data and complete box/delivery checks. + public const string Driver = "Driver"; + + /// All current application roles in hierarchy order. + public static readonly string[] AllRoles = + [ + Admin, + SM, + FOH, + Driver + ]; + + /// Roles that can view product, stock, location, and delivery status information. + public static readonly string[] ViewProductRoles = + [ + Admin, + SM, + FOH, + Driver + ]; + + /// Roles that can create, edit, or delete product/inventory records. + public static readonly string[] EditProductRoles = + [ Admin, - InventoryManager, - SalesStaff - }; -} \ No newline at end of file + SM + ]; + + /// Roles that can enter sales. + public static readonly string[] SalesInputRoles = + [ + Admin, + SM, + FOH + ]; + + /// Roles that can perform box and delivery checks. + public static readonly string[] DeliveryCheckRoles = + [ + Admin, + SM, + FOH, + Driver + ]; + + /// Roles that can manage application users and roles. + public static readonly string[] UserManagementRoles = + [ + Admin + ]; +} diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs index 886e1e7..9f38a9f 100644 --- a/Data/ApplicationDbContext.cs +++ b/Data/ApplicationDbContext.cs @@ -9,12 +9,20 @@ public class ApplicationDbContext(DbContextOptions options public DbSet Products { get; set; } public DbSet Categories { get; set; } +<<<<<<< HEAD + // Use the full path here to be 100% sure + public DbSet Images { get; set; } + public DbSet Locations { get; set; } + public DbSet ProductLocations { get; set; } + +======= public DbSet Images { get; set; } public DbSet Locations { get; set; } public DbSet ProductLocations { get; set; } public DbSet StockTransferBoxes { get; set; } public DbSet StockTransferBoxItems { get; set; } +>>>>>>> main public DbSet Inventories { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -36,8 +44,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(p => p.IsActive) .HasDefaultValue(true); +<<<<<<< HEAD + // Default check flags for existing and new product/location rows. + modelBuilder.Entity() + .Property(pl => pl.IsBoxChecked) + .HasDefaultValue(false); + + modelBuilder.Entity() + .Property(pl => pl.IsDeliveryChecked) + .HasDefaultValue(false); +======= modelBuilder.Entity() .HasIndex(pl => new { pl.ProductId, pl.LocationId }) .IsUnique(); +>>>>>>> main } } diff --git a/Data/ProductLocation.cs b/Data/ProductLocation.cs index 652be29..f08c47b 100644 --- a/Data/ProductLocation.cs +++ b/Data/ProductLocation.cs @@ -1,6 +1,5 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; // Add this using! +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace PuppetFestAPP.Web.Data; @@ -11,9 +10,9 @@ public class ProductLocation [Required] public int ProductId { get; set; } - [ValidateNever] // 1. Add this to stop the loop back to Product + [ValidateNever] public Product Product { get; set; } = null!; - + [Required] [Range(0, int.MaxValue, ErrorMessage = "Stock cannot be negative.")] public int Quantity { get; set; } = 0; @@ -21,8 +20,15 @@ public class ProductLocation [Required] public int LocationId { get; set; } - [ValidateNever] // 2. Add this to stop the loop into Location + [ValidateNever] public Location Location { get; set; } = null!; public DateTime LastUpdated { get; set; } = DateTime.UtcNow; -} \ No newline at end of file + + // Box/delivery check status is stored on the existing ProductLocation row + // so the app can track checks without introducing a separate workflow table. + public bool IsBoxChecked { get; set; } + public DateTime? BoxCheckedAt { get; set; } + public bool IsDeliveryChecked { get; set; } + public DateTime? DeliveryCheckedAt { get; set; } +} diff --git a/Data/SeedData.cs b/Data/SeedData.cs index efcf852..7cd33e6 100644 --- a/Data/SeedData.cs +++ b/Data/SeedData.cs @@ -9,6 +9,7 @@ // before creating it, so it's safe to run on every startup. using PuppetFestAPP.Web.Models; // This finds your new Image class using Image = PuppetFestAPP.Web.Models.Image; +using System.Data; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; @@ -113,11 +114,22 @@ public static async Task InitializeAsync(IServiceProvider serviceProvider) { Console.WriteLine( $" - Admin user already exists: {adminEmail}"); + + if (!await userManager.IsInRoleAsync(adminUser, AppRoles.Admin)) + { + await userManager.AddToRoleAsync(adminUser, AppRoles.Admin); + Console.WriteLine($" ✓ Added Admin role to {adminEmail}"); + } } + await EnsureEveryUserHasApplicationRoleAsync(userManager); + using var scope = serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); +<<<<<<< HEAD + EnsureDeliveryCheckColumns(context); +======= @@ -150,10 +162,135 @@ public static async Task InitializeAsync(IServiceProvider serviceProvider) +>>>>>>> main SeedProducts(context); } -#endregion + + /// + /// Gives existing accounts a valid application role after role changes. + /// Users without Admin/SM/FOH/Driver are assigned Driver by default. + /// If no Admin exists, the first available user is promoted to Admin. + /// + private static async Task EnsureEveryUserHasApplicationRoleAsync( + UserManager userManager) + { + var users = userManager.Users.ToList(); + + foreach (var user in users) + { + var roles = await userManager.GetRolesAsync(user); + var hasCurrentApplicationRole = roles.Any(role => AppRoles.AllRoles.Contains(role)); + + if (!hasCurrentApplicationRole) + { + await userManager.AddToRoleAsync(user, AppRoles.Driver); + Console.WriteLine($" ✓ Added default Driver role to {user.Email}"); + } + } + + var hasAdmin = false; + foreach (var user in users) + { + if (await userManager.IsInRoleAsync(user, AppRoles.Admin)) + { + hasAdmin = true; + break; + } + } + + if (!hasAdmin && users.Count > 0) + { + await userManager.AddToRoleAsync(users[0], AppRoles.Admin); + Console.WriteLine($" ✓ Promoted {users[0].Email} to Admin because no Admin existed."); + } + } + + /// + /// Adds delivery-check columns to the existing ProductLocations table. + /// This keeps the change narrowly scoped to the existing stock/location + /// model and avoids introducing a separate workflow table. + /// + private static void EnsureDeliveryCheckColumns(ApplicationDbContext context) + { + AddColumnIfMissing( + context, + "ProductLocations", + "IsBoxChecked", + "INTEGER NOT NULL DEFAULT 0"); + + AddColumnIfMissing( + context, + "ProductLocations", + "BoxCheckedAt", + "TEXT NULL"); + + AddColumnIfMissing( + context, + "ProductLocations", + "IsDeliveryChecked", + "INTEGER NOT NULL DEFAULT 0"); + + AddColumnIfMissing( + context, + "ProductLocations", + "DeliveryCheckedAt", + "TEXT NULL"); + } + + private static void AddColumnIfMissing( + ApplicationDbContext context, + string tableName, + string columnName, + string columnDefinition) + { + var connection = context.Database.GetDbConnection(); + var shouldClose = connection.State == ConnectionState.Closed; + + if (shouldClose) + { + connection.Open(); + } + + try + { + var columnExists = false; + + using (var command = connection.CreateCommand()) + { + command.CommandText = $"PRAGMA table_info({tableName});"; + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + if (string.Equals( + reader.GetString(1), + columnName, + StringComparison.OrdinalIgnoreCase)) + { + columnExists = true; + break; + } + } + } + + if (!columnExists) + { + using var alterCommand = connection.CreateCommand(); + alterCommand.CommandText = + $"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition};"; + alterCommand.ExecuteNonQuery(); + } + } + finally + { + if (shouldClose) + { + connection.Close(); + } + } + } + #endregion @@ -577,4 +714,4 @@ private static void SeedProducts(ApplicationDbContext context) } - #endregion \ No newline at end of file +#endregion \ No newline at end of file diff --git a/Migrations/20260420000100_AddProductLocationCheckFields.cs b/Migrations/20260420000100_AddProductLocationCheckFields.cs new file mode 100644 index 0000000..eee4cca --- /dev/null +++ b/Migrations/20260420000100_AddProductLocationCheckFields.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using PuppetFestAPP.Web.Data; + +#nullable disable + +namespace PuppetFestAPP.Web.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260420000100_AddProductLocationCheckFields")] + public partial class AddProductLocationCheckFields : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBoxChecked", + table: "ProductLocations", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "BoxCheckedAt", + table: "ProductLocations", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeliveryChecked", + table: "ProductLocations", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeliveryCheckedAt", + table: "ProductLocations", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBoxChecked", + table: "ProductLocations"); + + migrationBuilder.DropColumn( + name: "BoxCheckedAt", + table: "ProductLocations"); + + migrationBuilder.DropColumn( + name: "IsDeliveryChecked", + table: "ProductLocations"); + + migrationBuilder.DropColumn( + name: "DeliveryCheckedAt", + table: "ProductLocations"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index ba55d50..050671e 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,245 +18,258 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); + { + b.Property("Id") + .HasColumnType("TEXT"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); - b.ToTable("AspNetRoles", (string)null); - }); + b.ToTable("AspNetRoles", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ClaimType") - .HasColumnType("TEXT"); + b.Property("ClaimType") + .HasColumnType("TEXT"); - b.Property("ClaimValue") - .HasColumnType("TEXT"); + b.Property("ClaimValue") + .HasColumnType("TEXT"); - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetRoleClaims", (string)null); - }); + b.ToTable("AspNetRoleClaims", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ClaimType") - .HasColumnType("TEXT"); + b.Property("ClaimType") + .HasColumnType("TEXT"); - b.Property("ClaimValue") - .HasColumnType("TEXT"); + b.Property("ClaimValue") + .HasColumnType("TEXT"); - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserClaims", (string)null); - }); + b.ToTable("AspNetUserClaims", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("TEXT"); + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); - b.Property("ProviderKey") - .HasColumnType("TEXT"); + b.Property("ProviderKey") + .HasColumnType("TEXT"); - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); - b.HasKey("LoginProvider", "ProviderKey"); + b.HasKey("LoginProvider", "ProviderKey"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserLogins", (string)null); - }); + b.ToTable("AspNetUserLogins", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); + { + b.Property("UserId") + .HasColumnType("TEXT"); - b.Property("RoleId") - .HasColumnType("TEXT"); + b.Property("RoleId") + .HasColumnType("TEXT"); - b.HasKey("UserId", "RoleId"); + b.HasKey("UserId", "RoleId"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetUserRoles", (string)null); - }); + b.ToTable("AspNetUserRoles", (string)null); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); + { + b.Property("UserId") + .HasColumnType("TEXT"); - b.Property("LoginProvider") - .HasColumnType("TEXT"); + b.Property("LoginProvider") + .HasColumnType("TEXT"); - b.Property("Name") - .HasColumnType("TEXT"); + b.Property("Name") + .HasColumnType("TEXT"); - b.Property("Value") - .HasColumnType("TEXT"); + b.Property("Value") + .HasColumnType("TEXT"); - b.HasKey("UserId", "LoginProvider", "Name"); + b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens", (string)null); - }); + b.ToTable("AspNetUserTokens", (string)null); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); + { + b.Property("Id") + .HasColumnType("TEXT"); - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); - b.Property("LockoutEnd") - .HasColumnType("TEXT"); + b.Property("LockoutEnd") + .HasColumnType("TEXT"); - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.Property("PasswordHash") - .HasColumnType("TEXT"); + b.Property("PasswordHash") + .HasColumnType("TEXT"); - b.Property("PhoneNumber") - .HasColumnType("TEXT"); + b.Property("PhoneNumber") + .HasColumnType("TEXT"); - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); - b.Property("SecurityStamp") - .HasColumnType("TEXT"); + b.Property("SecurityStamp") + .HasColumnType("TEXT"); - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); - b.ToTable("AspNetUsers", (string)null); - }); + b.ToTable("AspNetUsers", (string)null); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("TEXT"); + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.ToTable("Categories"); - }); + b.ToTable("Categories"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Inventory", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("LastUpdated") - .HasColumnType("TEXT"); + b.Property("LastUpdated") + .HasColumnType("TEXT"); - b.Property("Location") - .HasMaxLength(50) - .HasColumnType("TEXT"); + b.Property("Location") + .HasMaxLength(50) + .HasColumnType("TEXT"); - b.Property("ProductId") - .HasColumnType("INTEGER"); + b.Property("ProductId") + .HasColumnType("INTEGER"); - b.Property("Quantity") - .HasColumnType("INTEGER"); + b.Property("Quantity") + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("ProductId"); + b.HasIndex("ProductId"); - b.ToTable("Inventories"); - }); + b.ToTable("Inventories"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Location", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + +<<<<<<< HEAD + b.Property("Address") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); +======= b.Property("Address") .IsRequired() .HasMaxLength(200) @@ -274,94 +287,115 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER"); b.HasKey("Id"); +>>>>>>> main - b.ToTable("Locations"); - }); + b.ToTable("Locations"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("CategoryId") - .HasColumnType("INTEGER"); + b.Property("CategoryId") + .HasColumnType("INTEGER"); - b.Property("Color") - .HasColumnType("INTEGER"); + b.Property("Color") + .HasColumnType("INTEGER"); - b.Property("DateAdded") - .HasColumnType("TEXT"); + b.Property("DateAdded") + .HasColumnType("TEXT"); - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("TEXT"); + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); - b.Property("ImageId") - .HasColumnType("INTEGER"); + b.Property("ImageId") + .HasColumnType("INTEGER"); - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true); + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); - b.Property("Material") - .HasMaxLength(100) - .HasColumnType("TEXT"); + b.Property("Material") + .HasMaxLength(100) + .HasColumnType("TEXT"); - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("TEXT"); + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); - b.Property("ParentProductId") - .HasColumnType("INTEGER"); + b.Property("ParentProductId") + .HasColumnType("INTEGER"); - b.Property("Price") - .HasPrecision(10, 2) - .HasColumnType("decimal(10,2)"); + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); - b.Property("Size") - .HasColumnType("INTEGER"); + b.Property("Size") + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CategoryId"); + b.HasIndex("CategoryId"); - b.HasIndex("ImageId"); + b.HasIndex("ImageId"); - b.HasIndex("ParentProductId"); + b.HasIndex("ParentProductId"); - b.ToTable("Products"); - }); + b.ToTable("Products"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.ProductLocation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("LastUpdated") - .HasColumnType("TEXT"); + b.Property("BoxCheckedAt") + .HasColumnType("TEXT"); - b.Property("LocationId") - .HasColumnType("INTEGER"); + b.Property("DeliveryCheckedAt") + .HasColumnType("TEXT"); - b.Property("ProductId") - .HasColumnType("INTEGER"); + b.Property("IsBoxChecked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); - b.Property("Quantity") - .HasColumnType("INTEGER"); + b.Property("IsDeliveryChecked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); - b.HasKey("Id"); + b.Property("LastUpdated") + .HasColumnType("TEXT"); - b.HasIndex("LocationId"); + b.Property("LocationId") + .HasColumnType("INTEGER"); +<<<<<<< HEAD + b.Property("ProductId") + .HasColumnType("INTEGER"); +======= b.HasIndex("ProductId", "LocationId") .IsUnique(); +>>>>>>> main - b.ToTable("ProductLocations"); - }); + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LocationId"); + + b.HasIndex("ProductId"); + + b.ToTable("ProductLocations"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b => { @@ -419,129 +453,129 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); modelBuilder.Entity("PuppetFestAPP.Web.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AltText") - .HasColumnType("TEXT"); + b.Property("AltText") + .HasColumnType("TEXT"); - b.Property("FileName") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("UploadDate") - .HasColumnType("TEXT"); + b.Property("UploadDate") + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.ToTable("Images"); - }); + b.ToTable("Images"); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", 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("PuppetFestAPP.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("PuppetFestAPP.Web.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Inventory", b => - { - b.HasOne("PuppetFestAPP.Web.Data.Product", "Product") - .WithMany("Inventories") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("PuppetFestAPP.Web.Data.Product", "Product") + .WithMany("Inventories") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.Navigation("Product"); - }); + b.Navigation("Product"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b => - { - b.HasOne("PuppetFestAPP.Web.Data.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("PuppetFestAPP.Web.Data.Category", "Category") + .WithMany("Products") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("PuppetFestAPP.Web.Models.Image", "Image") - .WithMany("Products") - .HasForeignKey("ImageId"); + b.HasOne("PuppetFestAPP.Web.Models.Image", "Image") + .WithMany("Products") + .HasForeignKey("ImageId"); - b.HasOne("PuppetFestAPP.Web.Data.Product", "ParentProduct") - .WithMany("Variants") - .HasForeignKey("ParentProductId"); + b.HasOne("PuppetFestAPP.Web.Data.Product", "ParentProduct") + .WithMany("Variants") + .HasForeignKey("ParentProductId"); - b.Navigation("Category"); + b.Navigation("Category"); - b.Navigation("Image"); + b.Navigation("Image"); - b.Navigation("ParentProduct"); - }); + b.Navigation("ParentProduct"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.ProductLocation", b => - { - b.HasOne("PuppetFestAPP.Web.Data.Location", "Location") - .WithMany("ProductLocations") - .HasForeignKey("LocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("PuppetFestAPP.Web.Data.Location", "Location") + .WithMany("ProductLocations") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("PuppetFestAPP.Web.Data.Product", "Product") - .WithMany("ProductLocations") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("PuppetFestAPP.Web.Data.Product", "Product") + .WithMany("ProductLocations") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.Navigation("Location"); + b.Navigation("Location"); - b.Navigation("Product"); - }); + b.Navigation("Product"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b => { @@ -582,23 +616,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Category", b => - { - b.Navigation("Products"); - }); + { + b.Navigation("Products"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Location", b => - { - b.Navigation("ProductLocations"); - }); + { + b.Navigation("ProductLocations"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.Product", b => - { - b.Navigation("Inventories"); + { + b.Navigation("Inventories"); - b.Navigation("ProductLocations"); + b.Navigation("ProductLocations"); - b.Navigation("Variants"); - }); + b.Navigation("Variants"); + }); modelBuilder.Entity("PuppetFestAPP.Web.Data.StockTransferBox", b => { @@ -606,9 +640,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); modelBuilder.Entity("PuppetFestAPP.Web.Models.Image", b => - { - b.Navigation("Products"); - }); + { + b.Navigation("Products"); + }); #pragma warning restore 612, 618 } } diff --git a/Program.cs b/Program.cs index e8a2416..0033ed3 100644 --- a/Program.cs +++ b/Program.cs @@ -18,12 +18,32 @@ builder.Services.AddScoped(); builder.Services.AddAuthentication(options => - { - options.DefaultScheme = IdentityConstants.ApplicationScheme; - options.DefaultSignInScheme = IdentityConstants.ExternalScheme; - }) +{ + options.DefaultScheme = IdentityConstants.ApplicationScheme; + options.DefaultSignInScheme = IdentityConstants.ExternalScheme; +}) .AddIdentityCookies(); +// Role-based authorization policies. These keep page access rules and +// navigation visibility aligned with the Admin > SM > FOH > Driver hierarchy. +builder.Services.AddAuthorization(options => +{ + options.AddPolicy(AppPolicies.CanViewProducts, policy => + policy.RequireRole(AppRoles.ViewProductRoles)); + + options.AddPolicy(AppPolicies.CanEditProducts, policy => + policy.RequireRole(AppRoles.EditProductRoles)); + + options.AddPolicy(AppPolicies.CanEnterSales, policy => + policy.RequireRole(AppRoles.SalesInputRoles)); + + options.AddPolicy(AppPolicies.CanManageDeliveryChecks, policy => + policy.RequireRole(AppRoles.DeliveryCheckRoles)); + + options.AddPolicy(AppPolicies.CanManageUsers, policy => + policy.RequireRole(AppRoles.UserManagementRoles)); +}); + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); builder.Services.AddDbContextFactory(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection") diff --git a/README.md b/README.md index 9c5e3e1..00de0d8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,25 @@ You can see the live branch here: [[https://backstagechicagopuppetfest-cff3bgbuh * Managed Azure deployment * Oversaw branch management * Logan (she/her) +<<<<<<< HEAD +* * Display Seed Data + * Sales page + +## Role-based access rules + +This project uses the following application roles: + +| Feature | Admin | SM | FOH | Driver | +| --- | --- | --- | --- | --- | +| View products, stock, locations, and check status | Yes | Yes | Yes | Yes | +| Box / delivery checks | Yes | Yes | Yes | Yes | +| Sales input | Yes | Yes | Yes | No | +| Product create/edit/delete buttons | Yes | Yes | No | No | +| Direct product create/edit/delete page access | Yes | Yes | No | No | +| User management / storage admin | Yes | No | No | No | + +The permission hierarchy is `Admin > SM > FOH > Driver`. Driver has the smallest permission set; there are no Driver-only features. +======= * Displayed the data * Developed Sales Page (Version 1) * Built Product, Location, and Catalog pages @@ -44,3 +63,4 @@ You can see the live branch here: [[https://backstagechicagopuppetfest-cff3bgbuh * Resolved merge conflicts across branches * Mia (She/Her) * Implemented email authentication system +>>>>>>> main diff --git a/Security/AppPolicies.cs b/Security/AppPolicies.cs new file mode 100644 index 0000000..bae10ca --- /dev/null +++ b/Security/AppPolicies.cs @@ -0,0 +1,10 @@ +namespace PuppetFestApp.web.Security; + +public static class AppPolicies +{ + public const string CanViewOperations = "CanViewOperations"; + public const string CanEnterSales = "CanEnterSales"; + public const string CanManageDeliveryChecks = "CanManageDeliveryChecks"; + public const string CanEditProducts = "CanEditProducts"; + public const string CanManageUsers = "CanManageUsers"; +} \ No newline at end of file diff --git a/Security/AppRoles.cs b/Security/AppRoles.cs new file mode 100644 index 0000000..abd4fe0 --- /dev/null +++ b/Security/AppRoles.cs @@ -0,0 +1,18 @@ +namespace PuppetFestApp.web.Security; + +public static class AppRoles +{ + public const string Admin = "Admin"; + public const string SM = "SM"; + public const string FOH = "FOH"; + public const string Driver = "Driver" + + public static readonly string[] All = [Admin, SM, FOH, Driver]; + public static readonly string[] EditRoles = [Admin, SM]; + public static readonly string[] Sales Roles = [Admin, SM, FOH]; + public static readonly string[] DeliveryCheckRoles= [Admin, SM, FOH, Driver]; + public static readonly string[] UserManagementRoles = [Admin]; + + public static bool IsKnownRole(string? role) => All.Contains(role); + +} \ No newline at end of file