Replace IsAdmin with role-based admin
Switch user admin handling from an AppUser boolean to ASP.NET Identity roles. Removed AppUser.IsAdmin and related configuration/model entries; added migration ReplaceIsAdminWithRoles to copy Users.IsAdmin=true into a persistent admin role and drop the IsAdmin column. CurrentUserResponse now exposes roles (string[]), AuthController returns ordered roles from UserManager, and IdentitySeedService now ensures the admin role exists and assigns/creates an initial admin user in that role. Program.cs registers an Admin-only policy (PolicyNames/RoleNames), adjusts cookie auth events to return 401/403 for API requests, and wires up authorization. Frontend updated to use roles: authSession normalizes roles, adds hasRole and ROLE_ADMIN, router and layout support meta.requiredRoles, and new Forbidden and AdminUsers pages/route are added. codexInfo.md updated to reflect the migration to role-based auth.
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string UserName { get; set; } = string.Empty;
|
public string UserName { get; set; } = string.Empty;
|
||||||
public bool IsAdmin { get; set; }
|
public List<string> Roles { get; set; } = new();
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public bool MustChangePassword { get; set; }
|
public bool MustChangePassword { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using API.Security;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Controllers.Auth
|
namespace API.Controllers.Auth
|
||||||
@@ -9,10 +9,10 @@ namespace API.Controllers.Auth
|
|||||||
public class AppUserController : ControllerBase
|
public class AppUserController : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize]
|
[Authorize(Policy = PolicyNames.AdminOnly)]
|
||||||
public async Task<IActionResult> GetAppUsers()
|
public IActionResult GetAppUsers()
|
||||||
{
|
{
|
||||||
return Ok();
|
return Ok(new { message = "Adminzugriff bestätigt." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ namespace API.Controllers.Auth
|
|||||||
if (user is null)
|
if (user is null)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
|
var roles = await userManager.GetRolesAsync(user);
|
||||||
|
|
||||||
return Ok(new CurrentUserResponse
|
return Ok(new CurrentUserResponse
|
||||||
{
|
{
|
||||||
Id = user.Id,
|
Id = user.Id,
|
||||||
UserName = user.UserName ?? string.Empty,
|
UserName = user.UserName ?? string.Empty,
|
||||||
IsAdmin = user.IsAdmin,
|
Roles = roles.OrderBy(x => x).ToList(),
|
||||||
IsActive = user.IsActive,
|
IsActive = user.IsActive,
|
||||||
MustChangePassword = user.MustChangePassword
|
MustChangePassword = user.MustChangePassword
|
||||||
});
|
});
|
||||||
@@ -114,4 +116,4 @@ namespace API.Controllers.Auth
|
|||||||
return Ok(new { message = "Passwort geändert. Du wurdest auf allen Geräten abgemeldet." });
|
return Ok(new { message = "Passwort geändert. Du wurdest auf allen Geräten abgemeldet." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ namespace API.Database.Configurations
|
|||||||
|
|
||||||
builder.Property(x => x.CreatedAt).IsRequired();
|
builder.Property(x => x.CreatedAt).IsRequired();
|
||||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||||
builder.Property(x => x.IsAdmin).IsRequired();
|
|
||||||
builder.Property(x => x.IsActive).IsRequired();
|
builder.Property(x => x.IsActive).IsRequired();
|
||||||
builder.Property(x => x.MustChangePassword).IsRequired();
|
builder.Property(x => x.MustChangePassword).IsRequired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using API.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20260420174609_ReplaceIsAdminWithRoles")]
|
||||||
|
partial class ReplaceIsAdminWithRoles
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.6")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Models.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("MustChangePassword")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("Users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("Roles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("RoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("UserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("UserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Models.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Models.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("API.Models.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("API.Models.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReplaceIsAdminWithRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
admin_role_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT "Id" INTO admin_role_id
|
||||||
|
FROM "Roles"
|
||||||
|
WHERE "NormalizedName" = 'ADMIN'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF admin_role_id IS NULL THEN
|
||||||
|
admin_role_id := '2b34c0e2-9d53-4d79-bb85-bff03ce9e1ee';
|
||||||
|
|
||||||
|
INSERT INTO "Roles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp")
|
||||||
|
VALUES (admin_role_id, 'admin', 'ADMIN', NULL)
|
||||||
|
ON CONFLICT ("Id") DO NOTHING;
|
||||||
|
|
||||||
|
SELECT "Id" INTO admin_role_id
|
||||||
|
FROM "Roles"
|
||||||
|
WHERE "NormalizedName" = 'ADMIN'
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "UserRoles" ("UserId", "RoleId")
|
||||||
|
SELECT u."Id", admin_role_id
|
||||||
|
FROM "Users" u
|
||||||
|
WHERE u."IsAdmin" = TRUE
|
||||||
|
AND admin_role_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "UserRoles" ur
|
||||||
|
WHERE ur."UserId" = u."Id"
|
||||||
|
AND ur."RoleId" = admin_role_id
|
||||||
|
);
|
||||||
|
END $$;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Users",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "Users" u
|
||||||
|
SET "IsAdmin" = TRUE
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "UserRoles" ur
|
||||||
|
INNER JOIN "Roles" r ON r."Id" = ur."RoleId"
|
||||||
|
WHERE ur."UserId" = u."Id"
|
||||||
|
AND r."NormalizedName" = 'ADMIN'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,9 +48,6 @@ namespace API.Migrations
|
|||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsAdmin")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
b.Property<bool>("LockoutEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace API.Models
|
|||||||
{
|
{
|
||||||
public class AppUser : IdentityUser<Guid>
|
public class AppUser : IdentityUser<Guid>
|
||||||
{
|
{
|
||||||
public bool IsAdmin { get; set; } = false;
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public bool MustChangePassword { get; set; } = false;
|
public bool MustChangePassword { get; set; } = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using API.Database;
|
using API.Database;
|
||||||
using API.Models;
|
using API.Models;
|
||||||
|
using API.Security;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.HttpLogging;
|
using Microsoft.AspNetCore.HttpLogging;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -50,6 +52,13 @@ builder.Services
|
|||||||
})
|
})
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(PolicyNames.AdminOnly, policy =>
|
||||||
|
{
|
||||||
|
policy.RequireRole(RoleNames.Admin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.ConfigureApplicationCookie(options =>
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
{
|
{
|
||||||
@@ -59,6 +68,31 @@ builder.Services.ConfigureApplicationCookie(options =>
|
|||||||
options.AccessDeniedPath = "/auth/forbidden";
|
options.AccessDeniedPath = "/auth/forbidden";
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnRedirectToLogin = context =>
|
||||||
|
{
|
||||||
|
if (IsApiRequest(context.Request))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.Redirect(context.RedirectUri);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnRedirectToAccessDenied = context =>
|
||||||
|
{
|
||||||
|
if (IsApiRequest(context.Request))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.Redirect(context.RedirectUri);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddScoped<IdentitySeedService>();
|
builder.Services.AddScoped<IdentitySeedService>();
|
||||||
@@ -112,3 +146,9 @@ app.MapFallback(async context =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
static bool IsApiRequest(HttpRequest request)
|
||||||
|
{
|
||||||
|
return request.Path.StartsWithSegments("/api")
|
||||||
|
|| request.Path.StartsWithSegments("/auth");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace API.Security
|
||||||
|
{
|
||||||
|
public static class PolicyNames
|
||||||
|
{
|
||||||
|
public const string AdminOnly = "admin-only";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace API.Security
|
||||||
|
{
|
||||||
|
public static class RoleNames
|
||||||
|
{
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
using API.Models;
|
using API.Models;
|
||||||
|
using API.Security;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace API.Services
|
namespace API.Services
|
||||||
{
|
{
|
||||||
public class IdentitySeedService(
|
public class IdentitySeedService(
|
||||||
UserManager<AppUser> userManager,
|
UserManager<AppUser> userManager,
|
||||||
|
RoleManager<IdentityRole<Guid>> roleManager,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<IdentitySeedService> logger)
|
ILogger<IdentitySeedService> logger)
|
||||||
{
|
{
|
||||||
public async Task SeedAsync()
|
public async Task SeedAsync()
|
||||||
{
|
{
|
||||||
var hasAdmin = await userManager.Users.AnyAsync(x => x.IsAdmin);
|
await EnsureRoleExistsAsync(RoleNames.Admin);
|
||||||
|
|
||||||
if (hasAdmin)
|
var adminUsers = await userManager.GetUsersInRoleAsync(RoleNames.Admin);
|
||||||
|
if (adminUsers.Count > 0)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Admin-Seed übersprungen: Es existiert bereits ein Admin-Account.");
|
logger.LogDebug("Admin-Seed übersprungen: Es existiert bereits ein Admin über Rollen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,30 +25,63 @@ namespace API.Services
|
|||||||
var adminPassword = configuration["SeedAdmin:Password"] ?? "HoardPassword";
|
var adminPassword = configuration["SeedAdmin:Password"] ?? "HoardPassword";
|
||||||
var adminEmail = configuration["SeedAdmin:Email"];
|
var adminEmail = configuration["SeedAdmin:Email"];
|
||||||
|
|
||||||
var admin = new AppUser
|
var admin = await userManager.FindByNameAsync(adminUserName);
|
||||||
|
if (admin is null)
|
||||||
{
|
{
|
||||||
UserName = adminUserName,
|
admin = new AppUser
|
||||||
Email = string.IsNullOrWhiteSpace(adminEmail) ? null : adminEmail,
|
{
|
||||||
IsAdmin = true,
|
UserName = adminUserName,
|
||||||
IsActive = true,
|
Email = string.IsNullOrWhiteSpace(adminEmail) ? null : adminEmail,
|
||||||
MustChangePassword = true,
|
IsActive = true,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
MustChangePassword = true,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
var result = await userManager.CreateAsync(admin, adminPassword);
|
var createResult = await userManager.CreateAsync(admin, adminPassword);
|
||||||
|
if (!createResult.Succeeded)
|
||||||
|
{
|
||||||
|
var createErrors = string.Join(", ", createResult.Errors.Select(x => x.Description));
|
||||||
|
logger.LogError("Admin-Seed fehlgeschlagen: {Errors}", createErrors);
|
||||||
|
throw new InvalidOperationException($"Admin-Seed fehlgeschlagen: {createErrors}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.Succeeded)
|
if (!await userManager.IsInRoleAsync(admin, RoleNames.Admin))
|
||||||
{
|
{
|
||||||
var errors = string.Join(", ", result.Errors.Select(x => x.Description));
|
var roleResult = await userManager.AddToRoleAsync(admin, RoleNames.Admin);
|
||||||
logger.LogError("Admin-Seed fehlgeschlagen: {Errors}", errors);
|
if (!roleResult.Succeeded)
|
||||||
throw new InvalidOperationException($"Admin-Seed fehlgeschlagen: {errors}");
|
{
|
||||||
|
var roleErrors = string.Join(", ", roleResult.Errors.Select(x => x.Description));
|
||||||
|
logger.LogError("Admin-Rollenzuweisung fehlgeschlagen: {Errors}", roleErrors);
|
||||||
|
throw new InvalidOperationException($"Admin-Rollenzuweisung fehlgeschlagen: {roleErrors}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Admin-Account wurde geseedet (UserName: {UserName}, Email: {Email}).",
|
"Admin-Account wurde geseedet bzw. als Rolle zugewiesen (UserName: {UserName}, Email: {Email}).",
|
||||||
admin.UserName,
|
admin.UserName,
|
||||||
admin.Email ?? "(keine)");
|
admin.Email ?? "(keine)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureRoleExistsAsync(string roleName)
|
||||||
|
{
|
||||||
|
if (await roleManager.RoleExistsAsync(roleName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var createRoleResult = await roleManager.CreateAsync(new IdentityRole<Guid>
|
||||||
|
{
|
||||||
|
Name = roleName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createRoleResult.Succeeded)
|
||||||
|
{
|
||||||
|
var roleErrors = string.Join(", ", createRoleResult.Errors.Select(x => x.Description));
|
||||||
|
logger.LogError("Rolle {RoleName} konnte nicht erstellt werden: {Errors}", roleName, roleErrors);
|
||||||
|
throw new InvalidOperationException($"Rolle {roleName} konnte nicht erstellt werden: {roleErrors}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Visibility, routes } from '@/plugins/routesLayout'
|
|||||||
import {
|
import {
|
||||||
AuthRequestError,
|
AuthRequestError,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
|
hasRole,
|
||||||
logout,
|
logout,
|
||||||
type CurrentUser,
|
type CurrentUser,
|
||||||
} from '@/services/authSession'
|
} from '@/services/authSession'
|
||||||
@@ -75,6 +76,18 @@ const sidebarRoutes = computed(() =>
|
|||||||
return currentUser.value === null
|
return currentUser.value === null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.visible === Visibility.Authorized) {
|
||||||
|
if (!currentUser.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.requiredRoles || item.requiredRoles.length === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.requiredRoles.every((role) => hasRole(currentUser.value, role))
|
||||||
|
}
|
||||||
|
|
||||||
if (item.visible !== Visibility.Route) {
|
if (item.visible !== Visibility.Route) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import type { RouteRecordRaw } from 'vue-router'
|
|||||||
import Home from '@/routes/Home.vue'
|
import Home from '@/routes/Home.vue'
|
||||||
import Dashboard from '@/routes/dashboard/Dashboard.vue'
|
import Dashboard from '@/routes/dashboard/Dashboard.vue'
|
||||||
import NotFound from '@/routes/404NotFound.vue'
|
import NotFound from '@/routes/404NotFound.vue'
|
||||||
|
import Forbidden from '@/routes/Forbidden.vue'
|
||||||
import Login from '@/routes/authentication/Login.vue'
|
import Login from '@/routes/authentication/Login.vue'
|
||||||
|
import AdminUsers from '@/routes/admin/AdminUsers.vue'
|
||||||
import Impressum from '@/routes/Impressum.vue'
|
import Impressum from '@/routes/Impressum.vue'
|
||||||
|
import { ROLE_ADMIN } from '@/services/authSession'
|
||||||
|
|
||||||
export enum Visibility {
|
export enum Visibility {
|
||||||
Hidden,
|
Hidden,
|
||||||
@@ -24,6 +27,7 @@ export interface LayoutRoute {
|
|||||||
disableFooter?: boolean
|
disableFooter?: boolean
|
||||||
visible: Visibility
|
visible: Visibility
|
||||||
visibilityRoute?: string | string[]
|
visibilityRoute?: string | string[]
|
||||||
|
requiredRoles?: string[]
|
||||||
meta?: RouteRecordRaw
|
meta?: RouteRecordRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +69,23 @@ export const routes: LayoutRoute[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/users',
|
||||||
|
name: 'Benutzer',
|
||||||
|
description: 'Adminbereich für Benutzerverwaltung',
|
||||||
|
icon: 'mdi-shield-account-outline',
|
||||||
|
visible: Visibility.Authorized,
|
||||||
|
requiredRoles: [ROLE_ADMIN],
|
||||||
|
meta: {
|
||||||
|
name: 'AdminUsers',
|
||||||
|
path: '/admin/users',
|
||||||
|
component: AdminUsers,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
requiredRoles: [ROLE_ADMIN],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
@@ -92,6 +113,21 @@ export const routes: LayoutRoute[] = [
|
|||||||
component: Impressum,
|
component: Impressum,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/forbidden',
|
||||||
|
name: 'Kein Zugriff',
|
||||||
|
description: 'Du hast keine Berechtigung für diese Seite',
|
||||||
|
icon: 'mdi-alert-circle-outline',
|
||||||
|
visible: Visibility.Hidden,
|
||||||
|
meta: {
|
||||||
|
path: '/forbidden',
|
||||||
|
name: 'Forbidden',
|
||||||
|
component: Forbidden,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/notFound',
|
path: '/notFound',
|
||||||
name: 'Nicht gefunden',
|
name: 'Nicht gefunden',
|
||||||
|
|||||||
+12
-2
@@ -1,6 +1,6 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import { routes } from '@/plugins/routesLayout'
|
import { routes } from '@/plugins/routesLayout'
|
||||||
import { fetchCurrentUser } from '@/services/authSession'
|
import { fetchCurrentUser, hasRole } from '@/services/authSession'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -10,8 +10,11 @@ const router = createRouter({
|
|||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const requiresAuth = to.meta.requiresAuth === true
|
const requiresAuth = to.meta.requiresAuth === true
|
||||||
const guestOnly = to.meta.guestOnly === true
|
const guestOnly = to.meta.guestOnly === true
|
||||||
|
const requiredRoles = Array.isArray(to.meta.requiredRoles)
|
||||||
|
? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string')
|
||||||
|
: []
|
||||||
|
|
||||||
if (!requiresAuth && !guestOnly) {
|
if (!requiresAuth && !guestOnly && requiredRoles.length === 0) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,13 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiredRoles.length > 0 && !requiredRoles.every((role) => hasRole(currentUser, role))) {
|
||||||
|
return {
|
||||||
|
name: 'Forbidden',
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (guestOnly && isAuthenticated) {
|
if (guestOnly && isAuthenticated) {
|
||||||
return {
|
return {
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { fetchCurrentUser } from '@/services/authSession'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
|
const primaryActionLabel = computed(() => (isAuthenticated.value ? 'Zum Dashboard' : 'Zum Login'))
|
||||||
|
|
||||||
|
async function resolveAuthState() {
|
||||||
|
try {
|
||||||
|
const user = await fetchCurrentUser({ force: true })
|
||||||
|
isAuthenticated.value = user !== null
|
||||||
|
} catch {
|
||||||
|
isAuthenticated.value = false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigatePrimaryAction() {
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
await router.replace({ name: 'Dashboard' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace({ name: 'Login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void resolveAuthState()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="forbidden-page hoard-page hoard-page--centered">
|
||||||
|
<section class="forbidden-shell hoard-shell-grid hoard-panel">
|
||||||
|
<header class="forbidden-head">
|
||||||
|
<p class="hoard-kicker">Fehlende Berechtigung</p>
|
||||||
|
<h1>Kein Zugriff</h1>
|
||||||
|
<p>Dein Konto hat keine ausreichende Rolle für diese Seite.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="forbidden-actions hoard-action-row">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-arrow-right"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="navigatePrimaryAction"
|
||||||
|
>
|
||||||
|
{{ primaryActionLabel }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forbidden-page {
|
||||||
|
--hoard-shell-width: min(640px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forbidden-shell {
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forbidden-head h1,
|
||||||
|
.forbidden-head p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forbidden-head h1 {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.forbidden-head p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { AuthRequestError, ROLE_ADMIN, fetchCurrentUser, hasRole } from '@/services/authSession'
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const responseMessage = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
async function loadAdminStatus() {
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUser = await fetchCurrentUser({ force: true })
|
||||||
|
if (!hasRole(currentUser, ROLE_ADMIN)) {
|
||||||
|
errorMessage.value = 'Dein Konto hat aktuell keine Admin-Rolle.'
|
||||||
|
responseMessage.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/auth/user', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AuthRequestError('Admin-Endpunkt konnte nicht geladen werden.', response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { message?: unknown }
|
||||||
|
responseMessage.value =
|
||||||
|
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||||
|
? payload.message
|
||||||
|
: 'Admin-Endpunkt erfolgreich erreicht.'
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthRequestError) {
|
||||||
|
errorMessage.value = error.message
|
||||||
|
} else {
|
||||||
|
errorMessage.value = 'Adminbereich konnte nicht geladen werden.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadAdminStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="admin-users-page hoard-page">
|
||||||
|
<section class="admin-users-shell hoard-panel">
|
||||||
|
<header class="admin-users-head">
|
||||||
|
<p class="hoard-kicker">Adminbereich</p>
|
||||||
|
<h1>Benutzerverwaltung</h1>
|
||||||
|
<p>Diese Seite ist nur für Konten mit Rolle <code>admin</code> sichtbar.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
border="start"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-else-if="responseMessage"
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
border="start"
|
||||||
|
>
|
||||||
|
{{ responseMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<p v-else-if="isLoading" class="admin-users-loading">Adminstatus wird geladen...</p>
|
||||||
|
|
||||||
|
<div class="admin-users-actions">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="loadAdminStatus"
|
||||||
|
>
|
||||||
|
Neu laden
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-users-page {
|
||||||
|
--hoard-page-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-head h1,
|
||||||
|
.admin-users-head p,
|
||||||
|
.admin-users-loading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-head h1 {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-head p {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-loading {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.admin-users-shell {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.admin-users-actions .v-btn) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string
|
id: string
|
||||||
userName: string
|
userName: string
|
||||||
isAdmin: boolean
|
roles: string[]
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
mustChangePassword: boolean
|
mustChangePassword: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ROLE_ADMIN = 'admin'
|
||||||
|
|
||||||
interface ApiMessageResponse {
|
interface ApiMessageResponse {
|
||||||
message?: unknown
|
message?: unknown
|
||||||
}
|
}
|
||||||
@@ -38,21 +40,38 @@ function normalizeCurrentUser(value: unknown): CurrentUser | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, userName, isAdmin, isActive, mustChangePassword } = value
|
const { id, userName, roles, isActive, mustChangePassword } = value
|
||||||
|
|
||||||
if (typeof id !== 'string' || typeof userName !== 'string') {
|
if (typeof id !== 'string' || typeof userName !== 'string') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedRoles = Array.isArray(roles)
|
||||||
|
? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0)
|
||||||
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
userName,
|
userName,
|
||||||
isAdmin: Boolean(isAdmin),
|
roles: normalizedRoles,
|
||||||
isActive: Boolean(isActive),
|
isActive: Boolean(isActive),
|
||||||
mustChangePassword: Boolean(mustChangePassword),
|
mustChangePassword: Boolean(mustChangePassword),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasRole(user: CurrentUser | null | undefined, role: string): boolean {
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRole = role.trim().toLowerCase()
|
||||||
|
if (normalizedRole.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.roles.some((userRole) => userRole.toLowerCase() === normalizedRole)
|
||||||
|
}
|
||||||
|
|
||||||
function isUnauthenticatedResponse(response: Response) {
|
function isUnauthenticatedResponse(response: Response) {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
+6
-2
@@ -91,11 +91,11 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
|
|||||||
- Swagger/OpenAPI ist im Backend nur im Development-Modus aktiv (`/swagger`).
|
- Swagger/OpenAPI ist im Backend nur im Development-Modus aktiv (`/swagger`).
|
||||||
- Frontend-Build (`npm run build` im `GUI`-Projekt) schreibt direkt nach `API/wwwroot`; das Backend liefert die SPA und statische Assets aus.
|
- Frontend-Build (`npm run build` im `GUI`-Projekt) schreibt direkt nach `API/wwwroot`; das Backend liefert die SPA und statische Assets aus.
|
||||||
- Backend nutzt jetzt PostgreSQL über `ConnectionStrings:Postgres` mit EF Core (`ApplicationDbContext`) und führt Migrationen beim Start automatisch aus.
|
- Backend nutzt jetzt PostgreSQL über `ConnectionStrings:Postgres` mit EF Core (`ApplicationDbContext`) und führt Migrationen beim Start automatisch aus.
|
||||||
- Backend nutzt ASP.NET Identity mit `AppUser` (Guid-Key, `IsAdmin`, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`) über PostgreSQL.
|
- Backend nutzt ASP.NET Identity mit `AppUser` (Guid-Key, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`) über PostgreSQL; Admin-Rechte laufen über Rollen (`admin`) statt User-Flag.
|
||||||
- `ApplicationDbContext` basiert auf `IdentityDbContext`; Identity-Tabellen sind auf `Users`, `Roles`, `UserRoles`, `UserClaims`, `UserLogins`, `RoleClaims` und `UserTokens` gemappt.
|
- `ApplicationDbContext` basiert auf `IdentityDbContext`; Identity-Tabellen sind auf `Users`, `Roles`, `UserRoles`, `UserClaims`, `UserLogins`, `RoleClaims` und `UserTokens` gemappt.
|
||||||
- Migration `InitIdentity` (`API/Migrations/20260418192723_InitIdentity.cs`) erstellt das Identity-Schema und wird beim Start automatisch angewendet.
|
- Migration `InitIdentity` (`API/Migrations/20260418192723_InitIdentity.cs`) erstellt das Identity-Schema und wird beim Start automatisch angewendet.
|
||||||
- Temporäre Test-Entity und Test-CRUD-Endpunkt (`api/test-items`) wurden wieder entfernt.
|
- Temporäre Test-Entity und Test-CRUD-Endpunkt (`api/test-items`) wurden wieder entfernt.
|
||||||
- Nach den Migrationen wird per `IdentitySeedService` ein initialer Admin angelegt, falls noch kein Admin existiert.
|
- Nach den Migrationen wird per `IdentitySeedService` die Rolle `admin` sichergestellt und einem initialen Admin-Account zugewiesen, falls noch kein Admin in dieser Rolle existiert.
|
||||||
- Für lokale Entwicklung liegt unter `API/Dev/docker-compose.yml` ein Stack mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
|
- Für lokale Entwicklung liegt unter `API/Dev/docker-compose.yml` ein Stack mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
|
||||||
- API lädt optional `API/appsettings.custom.json`; wenn vorhanden, überschreibt sie Werte aus `appsettings.json`.
|
- API lädt optional `API/appsettings.custom.json`; wenn vorhanden, überschreibt sie Werte aus `appsettings.json`.
|
||||||
- `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden.
|
- `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden.
|
||||||
@@ -170,3 +170,7 @@ Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für
|
|||||||
- `GUI/src/Layout.vue` Brand-Navigation nachgeschärft: `navigateToBrandTarget()` löst bei Klick zuerst die Session via `fetchCurrentUser()` auf (falls lokal noch `null`) und verhindert dadurch Fehlnavigation auf `Welcome`; Mobile-Account-Menütext auf `Zum Dashboard` vereinheitlicht.
|
- `GUI/src/Layout.vue` Brand-Navigation nachgeschärft: `navigateToBrandTarget()` löst bei Klick zuerst die Session via `fetchCurrentUser()` auf (falls lokal noch `null`) und verhindert dadurch Fehlnavigation auf `Welcome`; Mobile-Account-Menütext auf `Zum Dashboard` vereinheitlicht.
|
||||||
- Neuer globaler Skill `codexinfo-komprimieren` unter `C:/Users/famil/.codex/skills/codexinfo-komprimieren` erstellt; er liest `codexInfo.md`, verdichtet `Änderungen durch Codex` auf drei Kernzeilen und enthält ein Hilfsskript zum robusten Abschnitts-Update.
|
- Neuer globaler Skill `codexinfo-komprimieren` unter `C:/Users/famil/.codex/skills/codexinfo-komprimieren` erstellt; er liest `codexInfo.md`, verdichtet `Änderungen durch Codex` auf drei Kernzeilen und enthält ein Hilfsskript zum robusten Abschnitts-Update.
|
||||||
- `GUI/src/routes/404NotFound.vue` erweitert: Seite ermittelt beim Laden den Login-Status über `fetchCurrentUser()`, setzt CTA/Icon dynamisch auf `Zum Dashboard` oder `Zur Startseite` und leitet nach kurzer Verzögerung automatisch auf das passende Ziel weiter.
|
- `GUI/src/routes/404NotFound.vue` erweitert: Seite ermittelt beim Laden den Login-Status über `fetchCurrentUser()`, setzt CTA/Icon dynamisch auf `Zum Dashboard` oder `Zur Startseite` und leitet nach kurzer Verzögerung automatisch auf das passende Ziel weiter.
|
||||||
|
- Rollenbasiertes Auth-Modell umgesetzt: `IsAdmin` aus `AppUser`/Konfiguration entfernt, neue Rolle-Konstante `admin` eingeführt und Admin-Prüfungen auf Policy/Rollen (`API/Security/*`, `Authorize(Policy = admin-only)`) umgestellt.
|
||||||
|
- Neue Migration `ReplaceIsAdminWithRoles` (`API/Migrations/20260420174609_ReplaceIsAdminWithRoles.cs`) ergänzt: migriert bestehende `Users.IsAdmin = true` idempotent in `UserRoles` (`admin`) und entfernt anschließend die Spalte `IsAdmin`.
|
||||||
|
- `GET /auth/me` liefert jetzt `roles: string[]` statt `isAdmin`; `GUI/src/services/authSession.ts` wurde auf Rollen normalisiert und um `hasRole()` ergänzt.
|
||||||
|
- Frontend-Autorisierung erweitert: Router unterstützt `meta.requiredRoles`, neue 403-Seite `GUI/src/routes/Forbidden.vue` und admin-spezifische Route `GUI/src/routes/admin/AdminUsers.vue` werden nur für Rolle `admin` zugänglich/angezeigt.
|
||||||
|
|||||||
Reference in New Issue
Block a user