diff --git a/.env b/.env index fba0b89..2af583f 100644 --- a/.env +++ b/.env @@ -2,3 +2,9 @@ sike you thought I was like that hehehehee (urp so full) + +# TODO: should have basic public-safe environment variables here +# then secret environment variables can be added via secrets in the ci script like so: +# job: inject-seccrets $ echo API_KEY={{ secrets.API_KEY }} >> .env +# then they dont have to be inserted by the docker container ( messy) + diff --git a/api/Migrations/20260321221316_InitialCreate.Designer.cs b/api/Migrations/20260321221316_InitialCreate.Designer.cs deleted file mode 100644 index 1008d5d..0000000 --- a/api/Migrations/20260321221316_InitialCreate.Designer.cs +++ /dev/null @@ -1,279 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace agologum_api.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260321221316_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - 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"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - 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"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - 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("agologumApi.Models.User", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (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("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("agologumApi.Models.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("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/api/Migrations/20260322011947_AddItems.Designer.cs b/api/Migrations/20260322011947_AddItems.Designer.cs deleted file mode 100644 index b40ce57..0000000 --- a/api/Migrations/20260322011947_AddItems.Designer.cs +++ /dev/null @@ -1,306 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace agologum_api.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260322011947_AddItems")] - partial class AddItems - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - 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"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - 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"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - 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("agologumApi.Models.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("LastEditedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Items"); - }); - - modelBuilder.Entity("agologumApi.Models.User", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (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("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("agologumApi.Models.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("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("agologumApi.Models.User", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/api/Migrations/20260322011947_AddItems.cs b/api/Migrations/20260322011947_AddItems.cs deleted file mode 100644 index 324af3d..0000000 --- a/api/Migrations/20260322011947_AddItems.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace agologum_api.Migrations -{ - /// - public partial class AddItems : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Items", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - LastEditedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Items", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Items"); - } - } -} diff --git a/api/Migrations/20260322214843_AddRefreshTokens.cs b/api/Migrations/20260322214843_AddRefreshTokens.cs deleted file mode 100644 index f347d1d..0000000 --- a/api/Migrations/20260322214843_AddRefreshTokens.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace agologum_api.Migrations -{ - /// - public partial class AddRefreshTokens : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "RefreshTokens", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Token = table.Column(type: "text", nullable: false), - UserId = table.Column(type: "text", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), - IsRevoked = table.Column(type: "boolean", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_RefreshTokens", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "RefreshTokens"); - } - } -} diff --git a/api/Migrations/20260322214843_AddRefreshTokens.Designer.cs b/api/Migrations/20260423011426_InitialMigration.Designer.cs similarity index 98% rename from api/Migrations/20260322214843_AddRefreshTokens.Designer.cs rename to api/Migrations/20260423011426_InitialMigration.Designer.cs index 488c465..24bf37e 100644 --- a/api/Migrations/20260322214843_AddRefreshTokens.Designer.cs +++ b/api/Migrations/20260423011426_InitialMigration.Designer.cs @@ -11,8 +11,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace agologum_api.Migrations { [DbContext(typeof(AppDbContext))] - [Migration("20260322214843_AddRefreshTokens")] - partial class AddRefreshTokens + [Migration("20260423011426_InitialMigration")] + partial class InitialMigration { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -252,6 +252,9 @@ namespace agologum_api.Migrations b.Property("PasswordHash") .HasColumnType("text"); + b.PrimitiveCollection("Permissions") + .HasColumnType("jsonb"); + b.Property("PhoneNumber") .HasColumnType("text"); diff --git a/api/Migrations/20260321221316_InitialCreate.cs b/api/Migrations/20260423011426_InitialMigration.cs similarity index 82% rename from api/Migrations/20260321221316_InitialCreate.cs rename to api/Migrations/20260423011426_InitialMigration.cs index da54a51..7f0a4eb 100644 --- a/api/Migrations/20260321221316_InitialCreate.cs +++ b/api/Migrations/20260423011426_InitialMigration.cs @@ -7,7 +7,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace agologum_api.Migrations { /// - public partial class InitialCreate : Migration + public partial class InitialMigration : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -32,6 +32,7 @@ namespace agologum_api.Migrations { Id = table.Column(type: "text", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Permissions = table.Column(type: "jsonb", nullable: true), UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), @@ -52,6 +53,39 @@ namespace agologum_api.Migrations table.PrimaryKey("PK_AspNetUsers", x => x.Id); }); + migrationBuilder.CreateTable( + name: "Items", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastEditedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Items", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Token = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + migrationBuilder.CreateTable( name: "AspNetRoleClaims", columns: table => new @@ -214,6 +248,12 @@ namespace agologum_api.Migrations migrationBuilder.DropTable( name: "AspNetUserTokens"); + migrationBuilder.DropTable( + name: "Items"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + migrationBuilder.DropTable( name: "AspNetRoles"); diff --git a/api/Migrations/AppDbContextModelSnapshot.cs b/api/Migrations/AppDbContextModelSnapshot.cs index 0d019e7..0a94956 100644 --- a/api/Migrations/AppDbContextModelSnapshot.cs +++ b/api/Migrations/AppDbContextModelSnapshot.cs @@ -249,6 +249,9 @@ namespace agologum_api.Migrations b.Property("PasswordHash") .HasColumnType("text"); + b.PrimitiveCollection("Permissions") + .HasColumnType("jsonb"); + b.Property("PhoneNumber") .HasColumnType("text"); diff --git a/api/Program.cs b/api/Program.cs index 6bf5190..d4f8478 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,4 +1,5 @@ +// system usings using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -7,27 +8,33 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using System.Text; +// homeburger usings using agologumApi.Models; using agologumApi.Services; var builder = WebApplication.CreateBuilder(args); +// make sure the jwt key exists or else abort, security issue var key = builder.Configuration["Jwt:Key"]; if(key == null) return; +// connect to the sql database builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddControllers(); -// services +// add our services +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// if this grows sufficiently large we can put elsewhere // configuration for jwt authentication builder.Services.AddIdentity() .AddEntityFrameworkStores() - .AddDefaultTokenProviders(); + .AddDefaultTokenProviders() + .AddRoles(); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; @@ -44,7 +51,16 @@ builder.Services.AddAuthentication(options => { }; }); -builder.Services.AddAuthorization(); +// authorization configurations; here's where we register our permissions to policies +// TODO: this suspiciously looks able to be automated through a for loop, only if we can have a static dictionary maybe though? +builder.Services.AddAuthorization(options => { + + options.AddPolicy(Permission.SensitiveData_Read, policy => + policy.RequireClaim("permission", Permission.SensitiveData_Read)); + options.AddPolicy(Permission.SensitiveData_Modify, policy => + policy.RequireClaim("permission", Permission.SensitiveData_Modify)); + +}); // configuration for behind my nginx proxy builder.Services.Configure(options => @@ -61,6 +77,7 @@ builder.Services.Configure(options => // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); +// cors; scary needs to be fixed builder.Services.AddCors(options => { options.AddPolicy("dev", @@ -69,17 +86,14 @@ builder.Services.AddCors(options => policy.AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); - }); + }); // TODO: scary please fix this }); +// more middleware; probably uncessary at this stage builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -// https://www.reddit.com/r/dotnet/comments/1h7vzbs/how_do_you_guys_handle_authorization_on_a_web_api/ -// add authorization here -// controllers will have endpoints based on authorization -// frontend is a different story - +// build app var app = builder.Build(); app.UseForwardedHeaders(); @@ -111,6 +125,7 @@ using (var scope = app.Services.CreateScope()) { Thread.Sleep(5000); } } + } app.Run(); diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs index 8afd69e..0ecf3da 100644 --- a/api/src/Controllers/AuthController.cs +++ b/api/src/Controllers/AuthController.cs @@ -13,46 +13,62 @@ public class AuthController : ControllerBase { // identity things private readonly UserManager userManager_; private readonly SignInManager signInManager_; - + // services private readonly JwtService jwt_; + private readonly UserService userService_; - public AuthController(UserManager userManager, SignInManager signInManager, JwtService jwt) { + // class constructor (where are my initializer lists man) + public AuthController(UserManager userManager, SignInManager signInManager, JwtService jwt, UserService userService) { userManager_ = userManager; signInManager_ = signInManager; jwt_ = jwt; + userService_ = userService; } + // register endpoint [HttpPost("register")] public async Task Register(RegisterDto dto) { - var user = new User { + // create a new user out of the dto from the request + User user = new User { UserName = dto.UserName, Email = dto.Email, CreatedAt = DateTime.UtcNow // yeah why not utc }; + // assigning roles to user. create a user starting with x to give it permissions to read sensitive data + if(dto.UserName.StartsWith("x")) { + user.Permissions = new List { Permission.SensitiveData_Read }; + } + + // use Identity's user manager to add to db; error check if failed var result = await userManager_.CreateAsync(user, dto.Password); if(!result.Succeeded) return BadRequest(result.Errors); + // respond to post as necessary return CreatedAtAction( nameof(Register), new { id = user.Id } ); } + // login endpoint [HttpPost("login")] public async Task Login(LoginDto dto) { + // get the user from the database given the username var user = await userManager_.FindByNameAsync(dto.UserName); + // user not found with that name + if (user == null) return Unauthorized(); // unauthorized instead of not found to not give away info - if (user == null) return Unauthorized(); - + // use identity's password validation var result = await signInManager_.CheckPasswordSignInAsync(user, dto.Password, false); - + // if failed then youre not real ! if(!result.Succeeded) return Unauthorized(); - var accessToken = jwt_.GenerateJwt(user); - var refreshToken = jwt_.GenerateRefreshToken(); + // login sucess, give you an authentication token + var accessToken = await jwt_.GenerateJwt(user); + var refreshToken = jwt_.GenerateRefreshToken(); // the refresh token is good enough to refresh your access token RefreshToken newTokenObject = new RefreshToken { Token = refreshToken, UserId = user.Id, @@ -61,34 +77,44 @@ public class AuthController : ControllerBase { IsRevoked = false }; await jwt_.AddRefreshToken(newTokenObject); + // the jwt says we trust who you are and can substitute it for login + // contains permissions claims too + // return both access and refresh token return Ok(new { accessToken, refreshToken }); } + // logout endpoint [Authorize] // authorize is handled by middleware [HttpPost("logout")] public async Task Logout(string refreshTokenString) { // revoke refresh token bool success = await jwt_.RevokeRefreshToken(refreshTokenString); if(!success) return NotFound(); + // frontend refreshes page and detects logout return Ok(); } + // refresh token endpoint [HttpPost("refresh")] // allow-anonymous by default public async Task Refresh(TokenDto request) { + // reached when the frontend gets an unauthorized response and autoattempts to refresh if available + // get token from request and check if its valid RefreshToken? storedToken = await jwt_.GetRefreshToken(request.RefreshToken); if (storedToken == null) return Unauthorized(); bool valid = (storedToken.IsRevoked) || (storedToken.ExpiresAt < DateTime.UtcNow); if(!valid) return Unauthorized(); // TODO: delete the invalid token + // get user from the token and give them new tokens User? user = await jwt_.GetUser(storedToken.UserId); if(user == null) return NotFound(); - string? newAccessToken = jwt_.GenerateJwt(user); + string? newAccessToken = await jwt_.GenerateJwt(user); if(newAccessToken == null) return NotFound(); string newRefreshToken = jwt_.GenerateRefreshToken(); + // construct new token storedToken.IsRevoked = true; RefreshToken newTokenObject = new RefreshToken { Token = newRefreshToken, @@ -99,10 +125,9 @@ public class AuthController : ControllerBase { }; await jwt_.AddRefreshToken(newTokenObject); - + // return new tokens return Ok(new { accessToken = newAccessToken, refreshToken = newRefreshToken }); - } // TODO diff --git a/api/src/Controllers/UsersController.cs b/api/src/Controllers/UsersController.cs new file mode 100644 index 0000000..24339b0 --- /dev/null +++ b/api/src/Controllers/UsersController.cs @@ -0,0 +1,118 @@ + +// this is basically a demo on roles +// level 0 can't access the users endpoint at all +// level 1 has read permissions +// level 2 has modify permissions + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; + +using agologumApi.Models; +using agologumApi.Services; + +[ApiController] +[Route("api/[controller]")] +public class UsersController : ControllerBase { + + private readonly UserService service_; + + public UsersController(UserService service) { + service_ = service; + } + + [Authorize(Policy = Permission.SensitiveData_Read)] + [HttpGet] + public async Task>> getUsers() { + List rawArray = await service_.GetAll(); + + List dtoArray = new List(); + + foreach(User user in rawArray) { + UserDto newDto = new UserDto(user); + dtoArray.Add(newDto); + } + + return Ok(dtoArray); + } + + [Authorize(Policy = Permission.SensitiveData_Read)] + [HttpGet("{id:int}")] + public async Task> getUser(string id) { + + var user = await service_.GetById(id); + + if (user == null) return NotFound(); + + UserDto newDto = new UserDto(user); + + return Ok(newDto); + } + + [Authorize(Policy = Permission.SensitiveData_Modify)] + [HttpDelete("{id}")] + public async Task deleteUser(string id) { + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if(userId == id) return BadRequest(); // dont allow deletion of yourself + + var success = await service_.Delete(id); + + if (!success) return NotFound(); + + return NoContent(); + } + + [Authorize(Policy = Permission.SensitiveData_Modify)] + [HttpDelete("{id}/{permission}")] + public async Task removePermission(string id, string permission) { + + // get the user this request comes from. since it passed identity auth we can trust it + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if(permission == Permission.SensitiveData_Modify && userId == id) return BadRequest(); // dont allow permission removal of whats allowing us to re-add premissions + + // get list of permissions of that user + var user = await service_.GetById(id); + if (user == null) return NotFound(); + if(user.Permissions == null) return NotFound(); + + // verify that the requested permission exists on that user + if(!user.Permissions.Contains(permission)) return NotFound(); + + // remove the permission from the permission list + user.Permissions.Remove(permission); + + // update the user + await service_.Update(id, user); + + return NoContent(); + } + + [Authorize(Policy = Permission.SensitiveData_Modify)] + [HttpPost("{id}/{permission}")] // TODO: this was made with a single button per permission in mind, but may be better as sending an array + public async Task addPermission(string id, string permission) { + + // we'll allow the superuser to elevate their own permissions because they're the superuser + + // get list of permissions of the user + var user = await service_.GetById(id); + if (user == null) return NotFound(); + if(user.Permissions == null) return NotFound(); + + // remove add the permission to the user's permission list (if it doesnt already exist) + if(user.Permissions.Contains(permission)) return NoContent(); + user.Permissions.Add(permission); + + // update the user + await service_.Update(id, user); + + return NoContent(); + + // fyi the user will need to sign out and sign back in so the new permissions are reflected in their jwt claims + // TODO: or on the client i could issue a refresh token request after a permission api call + } + + // TODO: add controls on editing roles + +} \ No newline at end of file diff --git a/api/src/Data/AppDbContext.cs b/api/src/Data/AppDbContext.cs index 88af273..cd6b14f 100644 --- a/api/src/Data/AppDbContext.cs +++ b/api/src/Data/AppDbContext.cs @@ -10,8 +10,16 @@ public class AppDbContext : IdentityDbContext { } - // Db set for each model besides Users (DbSet is already defined in IdenityDbContext) + // Db set for each model besides Users (DbSet is already defined in IdentityDbContext) public DbSet Items { get; set; } public DbSet RefreshTokens { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { + + base.OnModelCreating(builder); + + builder.Entity().Property(u => u.Permissions).HasColumnType("jsonb"); + + } + } \ No newline at end of file diff --git a/api/src/Models/Permissions.cs b/api/src/Models/Permissions.cs new file mode 100644 index 0000000..39e141c --- /dev/null +++ b/api/src/Models/Permissions.cs @@ -0,0 +1,10 @@ + +// this is a static data model; it doesnt exist in a database (yet) +// lol no dynamic permissions would mean endpoint authorization gates need to be dynamic too + +public static class Permission { + + public const string SensitiveData_Read = "SensitiveData.Read"; + public const string SensitiveData_Modify = "SensitiveData.Modify"; + +} \ No newline at end of file diff --git a/api/src/Models/User.cs b/api/src/Models/User.cs index 98bdb7c..a542617 100644 --- a/api/src/Models/User.cs +++ b/api/src/Models/User.cs @@ -7,6 +7,11 @@ public class User : IdentityUser { public DateTime CreatedAt { get; set; } + // TODO: make this a list of UserPermissions + // where a userpermission has an Id, Permission (string), and userId string + // then we can do something like: get all users with this permission + public List? Permissions { get; set; } = new(); // because this isnt very relational database happy + // properties inherited from IdentityUser: /* AccessFailedCount: Gets or sets the number of failed login attempts for the current user. @@ -46,3 +51,23 @@ public class LoginDto { public string Password { get; set; } = ""; } + +public class UserDto { + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // datetimes get compressed to a string + public List? Permissions { get; set; } = []; + public string? Email { get; set; } = ""; + public string Id { get; set; } = ""; + public string? UserName { get; set; } = ""; + + // constructor out of a full User object + // REMEMBER: when adding fields to UserDto they must also be set in this constructor or else stuff breaks + public UserDto(User user) { + CreatedAt = user.CreatedAt; + Email = user.Email; + Id = user.Id; + UserName = user.UserName; + Permissions = user.Permissions; + } + +}; diff --git a/api/src/Services/ItemService.cs b/api/src/Services/ItemService.cs index 8fab9ff..8535a6f 100644 --- a/api/src/Services/ItemService.cs +++ b/api/src/Services/ItemService.cs @@ -5,6 +5,7 @@ using agologumApi.Models; namespace agologumApi.Services; +// basic CRUD operations for items in the database public class ItemService { private readonly AppDbContext db_; diff --git a/api/src/Services/JwtService.cs b/api/src/Services/JwtService.cs index 54f1c15..b2c7c19 100644 --- a/api/src/Services/JwtService.cs +++ b/api/src/Services/JwtService.cs @@ -5,6 +5,7 @@ using System.Text; using System.Security.Claims; using System.IdentityModel.Tokens.Jwt; using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; using agologumApi.Models; @@ -12,33 +13,46 @@ public class JwtService { private readonly IConfiguration config_; private readonly AppDbContext db_; + private readonly UserManager userManager_; - public JwtService(IConfiguration config, AppDbContext db) { // why the heck does c# not have initializer lists ? + public JwtService(IConfiguration config, AppDbContext db, UserManager userManager) { // why the heck does c# not have initializer lists ? config_ = config; db_ = db; + userManager_ = userManager; } - public string? GenerateJwt(User user) { + // create a jwt string given a user (user contains permissions which go into claims) + public async Task GenerateJwt(User user) { + // security stuff string? jwtKey = config_["Jwt:Key"]; if(jwtKey == null) return null; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - + // make sure the user is real if(user.UserName == null) return null; // not too sure - var claims = new[] { + var claims = new List { new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()) }; + // add each permission that the user has into the claims + List? permissions = user.Permissions; + if(permissions != null) { + foreach(string perm in permissions) { + claims.Add(new Claim("permission", perm)); + } + } + + // construct that token var token = new JwtSecurityToken( issuer: "agologum", audience: "agologum", claims: claims, - expires: DateTime.UtcNow.AddHours(2), // will add a refresher later + expires: DateTime.UtcNow.AddHours(2), signingCredentials: creds ); @@ -46,6 +60,7 @@ public class JwtService { } + // generating a refresh token is just like a long random password public string GenerateRefreshToken() { byte[] randomBytes = new byte[64]; @@ -54,10 +69,12 @@ public class JwtService { } + // we store refresh tokens on our side to check against when a user requests a refresh public async Task GetRefreshToken(string refreshTokenString) { return await db_.RefreshTokens.FirstOrDefaultAsync(u => u.Token == refreshTokenString); } + // add a refresh token to the token db store public async Task AddRefreshToken(RefreshToken refreshToken) { db_.RefreshTokens.Add(refreshToken); await db_.SaveChangesAsync(); @@ -67,8 +84,9 @@ public class JwtService { // helper to get the User from the id that exists in a refresh token object public async Task GetUser(string id) { return await db_.Users.FindAsync(id); - } + } // since other places aren't good for having references to db contexts + // remove refresh token from our store; called when user logs out public async Task RevokeRefreshToken(string refreshTokenString) { var refreshToken = await db_.RefreshTokens.FirstOrDefaultAsync(u => u.Token == refreshTokenString); if(refreshToken == null) return false; diff --git a/api/src/Services/UserService.cs b/api/src/Services/UserService.cs new file mode 100644 index 0000000..1800523 --- /dev/null +++ b/api/src/Services/UserService.cs @@ -0,0 +1,55 @@ + +using Microsoft.EntityFrameworkCore; + +using agologumApi.Models; + +namespace agologumApi.Services; + +public class UserService { + + private readonly AppDbContext db_; + + public UserService(AppDbContext db) { + db_ = db; + } + + // get all users + public async Task> GetAll() { + return await db_.Users.ToListAsync(); + } + + // get one user with id of id + public async Task GetById(string id) { + return await db_.Users.FindAsync(id); + } + + // get one user with username of name + public async Task GetByName(string name) { + return await db_.Users.FirstOrDefaultAsync(u => u.UserName == name); + } + + // delete one user with id of id + public async Task Delete(string id) { + User? User = await db_.Users.FindAsync(id); + if(User != null) { + db_.Users.Remove(User); + await db_.SaveChangesAsync(); + return true; + } else { + return false; + } + } + + // update user of id with user + public async Task Update(string id, User user) { + + User? oldUser = await db_.Users.FindAsync(id); + if(oldUser == null) return oldUser; + + oldUser.Permissions = user.Permissions; + + await db_.SaveChangesAsync(); + return oldUser; + } + +} \ No newline at end of file diff --git a/client/src/api/AuthApi.ts b/client/src/api/AuthApi.ts index 62b4a0a..b4bc8c9 100644 --- a/client/src/api/AuthApi.ts +++ b/client/src/api/AuthApi.ts @@ -3,7 +3,7 @@ // handles user registration, user logins, tokens, password reset, etc. import { api, authStorage } from "./axios.ts" -import type { User, RegisterDto, LoginDto } from "../models/User.ts"; +import type { UserDto, RegisterDto, LoginDto } from "../models/User.ts"; const API_URL: string = "/auth"; diff --git a/client/src/api/UsersApi.ts b/client/src/api/UsersApi.ts new file mode 100644 index 0000000..54252b2 --- /dev/null +++ b/client/src/api/UsersApi.ts @@ -0,0 +1,15 @@ + +import api from "./axios.ts" +import type { UserDto } from "../models/User.ts"; + +const API_URL: string = "/users"; + +export const getUsers = () => api.get(`${API_URL}`); + +export const getUser = (id: string) => api.get(`${API_URL}/${id}`); + +export const deleteUser = (id: string) => api.delete(`${API_URL}/${id}`); + +export const removePermission = (id: string, permission: string) => api.delete(`${API_URL}/${id}/${permission}`) + +export const addPermission = (id: string, permission: string) => api.post(`${API_URL}/${id}/${permission}`) diff --git a/client/src/api/axios.ts b/client/src/api/axios.ts index 8d05317..8a48736 100644 --- a/client/src/api/axios.ts +++ b/client/src/api/axios.ts @@ -87,4 +87,7 @@ api.interceptors.response.use(response => response, async error => { // mainly f return Promise.reject(error); }) +// TODO: if you get a 403 while navigating then redirect to the last authenticated page +// if you gert a 403 on a form submissio nthen do like an unauthorized popup (message: stale session ) (or redirect to login like i said elsewhere) + export default api; diff --git a/client/src/components/UsersTable.vue b/client/src/components/UsersTable.vue new file mode 100644 index 0000000..e69de29 diff --git a/client/src/models/User.ts b/client/src/models/User.ts index 48d8fcd..48c9573 100644 --- a/client/src/models/User.ts +++ b/client/src/models/User.ts @@ -2,20 +2,21 @@ // models are the data objects stored in the database. models defined here must match models defined in api/models // dtos here must match the the dtos in api/src/Modelts/Dto.cs in name (case insensitive) (types are intermediately serialized to strings) -export interface User { - id: number; - username: string; +export interface UserDto { + createdAt: string; email: string; - password: string; + id: string; + userName: string; + permissions: string; } export interface RegisterDto { - username: string; + userName: string; email: string; password: string; } export interface LoginDto { - username: string; + userName: string; password: string; } diff --git a/client/src/pages/LoginForm.vue b/client/src/pages/LoginForm.vue index ffe445f..2678e15 100644 --- a/client/src/pages/LoginForm.vue +++ b/client/src/pages/LoginForm.vue @@ -10,7 +10,7 @@ import * as authApi from "../api/AuthApi"; const router = useRouter(); const user = reactive({ // the template ensures type consistency - username: "", + userName: "", password: "", }); @@ -40,7 +40,7 @@ async function login(): Promise { Login - + Submit diff --git a/client/src/pages/RegisterForm.vue b/client/src/pages/RegisterForm.vue index 28a461c..e0a7ef4 100644 --- a/client/src/pages/RegisterForm.vue +++ b/client/src/pages/RegisterForm.vue @@ -10,7 +10,7 @@ import * as authApi from "../api/AuthApi"; const router = useRouter(); const user = reactive({ // the template ensures type consistency - username: "", + userName: "", email: "", password: "", }); @@ -40,7 +40,7 @@ async function register(): Promise { Register - + diff --git a/client/src/pages/UsersList.vue b/client/src/pages/UsersList.vue new file mode 100644 index 0000000..cd978c8 --- /dev/null +++ b/client/src/pages/UsersList.vue @@ -0,0 +1,55 @@ + + + + + + Users + + + + {{ user.userName }} + + Delete + + + Remove {{ perm }} permission + + + + + Add Permission + + + + + Logout + + diff --git a/client/src/pages/index.vue b/client/src/pages/index.vue index 0a8cab5..e6999c1 100644 --- a/client/src/pages/index.vue +++ b/client/src/pages/index.vue @@ -10,10 +10,16 @@ yeah im so cool rn imagining what I could do with themes :o + TODO: if(logged in) show this stuff; else dont. + Items + + Users + + Register diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 183b38a..3d09595 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -6,6 +6,7 @@ import LoginForm from "../pages/LoginForm.vue"; import RegisterForm from "../pages/RegisterForm.vue"; import ItemsList from "../pages/ItemsList.vue"; import ItemForm from "../pages/ItemForm.vue"; +import UsersList from "../pages/UsersList.vue"; import index from "../pages/index.vue"; import { authStorage } from "../api/axios.ts" @@ -17,7 +18,8 @@ const routes = [ { path: "/register", component: RegisterForm }, { path: "/items", component: ItemsList, meta: { requiresAuth: true } }, { path: "/item/new", component: ItemForm, meta: { requiresAuth: true } }, - { path: "/item/:id", component: ItemForm, meta: { requiresAuth: true } } + { path: "/item/:id", component: ItemForm, meta: { requiresAuth: true } }, + { path: "/users", component: UsersList, meta: { requiresAuth: true } } ]; // I really like this const router = createRouter({ @@ -36,5 +38,6 @@ router.beforeEach((to, from, next) => { } }); +// if the api responds unauthorized (401) then it also will auto-redirect to the login page export default router; diff --git a/client/src/stores/UsersStore.ts b/client/src/stores/UsersStore.ts new file mode 100644 index 0000000..70dab24 --- /dev/null +++ b/client/src/stores/UsersStore.ts @@ -0,0 +1,40 @@ + +import { defineStore } from "pinia"; +import type { UserDto } from "../models/User.ts"; +import * as usersApi from "../api/UsersApi"; + +interface UserState { + users: UserDto[]; + loading: boolean; +} + +export const useUsersStore = defineStore("users", { + + state: (): UserState => ({ + users: [], + loading: false + }), + + actions: { + async fetchUsers() { + this.loading = true; + const response = await usersApi.getUsers(); + this.users = response.data; + this.loading = false; + }, + + async removeUser(id: string) { + await usersApi.deleteUser(id); + this.users = this.users.filter(i => i.id !== id); + }, + + async removePermission(id: string, permission: string) { + await usersApi.removePermission(id, permission); + }, + + async addPermission(id: string, permission: string) { + await usersApi.addPermission(id, permission); + } + } + +}); diff --git a/scripts/DEV_README.md b/scripts/DEV_README.md index bfee97f..71b6400 100644 --- a/scripts/DEV_README.md +++ b/scripts/DEV_README.md @@ -13,7 +13,8 @@ To see live logs: sudo docker logs -f -t agologum-api public user: -> username=bard +> username=bard (admin) +> username=xvbard (superuser) > password=Public*890 chrome dev tools troubleshooting