13 Commits

Author SHA1 Message Date
fddbc1e2ce Merge remote-tracking branch 'origin/identity/develop' into identity/develop 2026-02-03 20:30:00 +01:00
e21e6aa96d Fix formatting in PlunkEmailSender by adding a newline for improved readability 2026-02-03 20:29:16 +01:00
Jonas
2ee1f68c3f Merge pull request #4 from kobolol/claude/create-readme-awk06 2026-02-03 06:53:52 +01:00
Claude
5ff0df8685 Add comprehensive README with project documentation
Include project description, tech stack, structure, setup instructions,
available scripts, database configuration, and API endpoints.

https://claude.ai/code/session_01UFj67Xe3r5qqRaq4UMRhpa
2026-02-03 05:42:21 +00:00
ce26a30693 Refactored AgeGroup namespace and services, added RegistrationKey functionality. 2026-01-25 21:30:05 +01:00
91dd8d1603 Add AuthController and configure authentication/identity services 2026-01-25 15:29:28 +01:00
d1a72876d2 Implement Plunk email sending service with HttpClient integration and DTO definition. 2026-01-25 13:43:25 +01:00
Jonas
ba2a455a2b Delete API/app.db 2026-01-25 13:41:54 +01:00
44b39b4393 Add initial implementation of PlunkEmailSender and update .gitignore.
- Removed `appsettings.Development.json` and excluded it from version control.
- Updated `API.csproj` to remove unnecessary folder reference.
2026-01-25 13:17:14 +01:00
facc84157c Add footer with links and update UI/UX consistency 2026-01-25 13:00:25 +01:00
e4862f1878 - Enhance 404 page design and add link to home
- Update routes to include "Login" page
- Add dynamic website title updates based on current route
- Add "Login" button to layout linking to Login page
2026-01-23 22:59:32 +01:00
e0ecdad408 - Enable UseAuthentication middleware.
- Fix variable typo from `postgreConnection` to `postgresConnection`.
- Update `.gitignore` to exclude SQLite database files.
2026-01-23 21:52:20 +01:00
534ec5f36f Add authentication and identity support using ASP.NET Core Identity 2026-01-23 21:28:16 +01:00
32 changed files with 1352 additions and 88 deletions

10
.gitignore vendored
View File

@@ -85,4 +85,12 @@ API/app.db-shm
# Vite build output # Vite build output
GUI/dist/ GUI/dist/
wwwroot/ wwwroot/
# SQLite Datenbank-Dateien
*.db
*.db-shm
*.db-wal
# AppSettings Development files
appsettings.Development.json

View File

@@ -18,6 +18,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -29,8 +30,4 @@
<PackageReference Include="Ulid" Version="1.4.1" /> <PackageReference Include="Ulid" Version="1.4.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Services\" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,5 @@
using API.Models.Ingoing.Altersgruppen; using API.Models.Internal.Altersgruppen;
using API.Repository.AgeGroup; using API.Repository.AgeGroupRepo;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace API.Controllers namespace API.Controllers

View File

@@ -0,0 +1,16 @@
using API.Models.Internal.User;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[ApiController]
[Route("api/account")]
public class AuthController(UserManager<User> userManager, SignInManager<User> signInManager, IEmailSender emailSender)
: ControllerBase
{
private readonly UserManager<User> _userManager = userManager;
private readonly SignInManager<User> _signInManager = signInManager;
private readonly IEmailSender _emailSender = emailSender;
}

View File

@@ -0,0 +1,21 @@
using API.Repository.RegistrationKeyRepo;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[ApiController]
[Route("api/registrationKey")]
public class RegistrationKeyController : ControllerBase
{
private IRegistrationKeyService _keyService;
private readonly RoleManager<IdentityRole> _roleManager;
public RegistrationKeyController(IRegistrationKeyService keyService, RoleManager<IdentityRole> roleManager)
{
_keyService = keyService;
_roleManager = roleManager;
}
}

View File

@@ -1,15 +1,18 @@
using API.Models.Internal.Altersgruppen; using API.Models.Internal.Altersgruppen;
using API.Models.Internal.User;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Database namespace API.Database
{ {
public class ApplicationDbContext : DbContext public class ApplicationDbContext : IdentityDbContext<User>
{ {
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
{ {
} }
public DbSet<AltersGruppe> Altersgruppen { get; set; } public DbSet<AltersGruppe> Altersgruppen { get; set; }
public DbSet<RegistrationKey> RegistrationKeys { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -0,0 +1,13 @@
using API.Models.Internal.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace API.Database.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> entity)
{
entity.Property(e => e.Ininitals).HasMaxLength(5);
}
}

View File

@@ -0,0 +1,294 @@
// <auto-generated />
using System;
using API.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260123202637_InitialIdentity")]
partial class InitialIdentity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("API.Models.Internal.Altersgruppen.AltersGruppe", b =>
{
b.Property<string>("Id")
.HasMaxLength(26)
.HasColumnType("TEXT");
b.Property<int>("EndingAge")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("StartingAge")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Altersgruppen");
});
modelBuilder.Entity("API.Models.Internal.User.User", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Ininitals")
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
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("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,223 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class InitialIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Ininitals = table.Column<string>(type: "TEXT", maxLength: 5, nullable: true),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using API.Database; using API.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -37,6 +38,253 @@ namespace API.Migrations
b.ToTable("Altersgruppen"); b.ToTable("Altersgruppen");
}); });
modelBuilder.Entity("API.Models.Internal.User.User", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Ininitals")
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
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("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("API.Models.Internal.User.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@@ -1,28 +0,0 @@
using API.Models.Internal.Altersgruppen;
using System.ComponentModel.DataAnnotations;
namespace API.Models.Ingoing.Altersgruppen
{
public class AltersGruppeIngoing
{
[Required]
public string Name { get; set; }
[Required]
public int StartingAge { get; set; }
[Required]
public int EndingAge { get; set; }
}
public static class AltersgruppeMapper
{
public static AltersGruppe ToInternalFromIngoing(this AltersGruppeIngoing group)
{
return new AltersGruppe
{
Name = group.Name,
StartingAge = group.StartingAge,
EndingAge = group.EndingAge,
};
}
}
}

View File

@@ -4,15 +4,32 @@ namespace API.Models.Internal.Altersgruppen
{ {
public class AltersGruppe public class AltersGruppe
{ {
public string Id { get; set; } public string Id { get; set; } = Ulid.NewUlid().ToString();
public string Name { get; set; } public required string Name { get; set; }
public int StartingAge { get; set; } public int StartingAge { get; set; }
public int EndingAge { get; set; } public int EndingAge { get; set; }
}
// Constructor für automatische ULID-Generierung public class AltersGruppeIngoing
public AltersGruppe() {
[Required]
public string Name { get; set; }
[Required]
public int StartingAge { get; set; }
[Required]
public int EndingAge { get; set; }
}
public static class AltersgruppeMapper
{
public static AltersGruppe ToInternalFromIngoing(this AltersGruppeIngoing group)
{ {
Id = Ulid.NewUlid().ToString(); return new AltersGruppe
{
Name = group.Name,
StartingAge = group.StartingAge,
EndingAge = group.EndingAge,
};
} }
} }
} }

View File

@@ -0,0 +1,24 @@
namespace API.Models.Internal.User;
public class RegistrationKey
{
public string Id { get; set; } = Ulid.NewUlid().ToString();
public string? LinkedRole { get; set; }
public DateTime Created { get; set; } = DateTime.UtcNow;
}
public class RegistrationKeyIngoing
{
public string? LinkedRole { get; set; }
}
public static class RegistrationKeyMapper
{
public static RegistrationKey ToInternalFromIngoing(this RegistrationKeyIngoing key)
{
return new RegistrationKey
{
LinkedRole = key.LinkedRole,
};
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace API.Models.Internal.User;
public class User : IdentityUser
{
public string? Ininitals { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace API.Models.Outgoing;
public class PlunkEmailDto
{
public required string To { get; set; }
public required string Subject { get; set; }
public required string Body { get; set; }
public string? Name { get; set; }
}

View File

@@ -1,5 +1,9 @@
using API.Database; using API.Database;
using API.Repository.AgeGroup; using API.Models.Internal.User;
using API.Repository.AgeGroupRepo;
using API.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var webRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "wwwroot")); var webRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "wwwroot"));
@@ -14,18 +18,33 @@ builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// Authentication
builder.Services.AddAuthorization();
builder.Services.AddAuthentication()
.AddCookie(IdentityConstants.ApplicationScheme);
builder.Services.AddIdentityCore<User>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.User.RequireUniqueEmail = true;
}).AddEntityFrameworkStores<ApplicationDbContext>().AddRoles<IdentityRole>().AddDefaultTokenProviders();
// Database // Database
var postgreConnection = builder.Configuration.GetConnectionString("PostgresConnection"); var postgresConnection = builder.Configuration.GetConnectionString("PostgresConnection");
if (!string.IsNullOrEmpty(postgreConnection)) if (!string.IsNullOrEmpty(postgresConnection))
{ {
// Nutze PostgresSQL // Nutze PostgresSQL
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseNpgsql(postgreConnection)); builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseNpgsql(postgresConnection));
} }
else else
{ {
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite("Data Source=app.db")); builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlite("Data Source=app.db"));
} }
// Adding Email Service (Plunk)
builder.Services.AddHttpClient<IEmailSender, PlunkEmailSender>();
// Adding Database Services // Adding Database Services
builder.Services.AddScoped<IAgeGroupService, AgeGroupService>(); builder.Services.AddScoped<IAgeGroupService, AgeGroupService>();
@@ -54,7 +73,7 @@ app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();

View File

@@ -2,7 +2,7 @@
using API.Models.Internal.Altersgruppen; using API.Models.Internal.Altersgruppen;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Repository.AgeGroup namespace API.Repository.AgeGroupRepo
{ {
public class AgeGroupService : IAgeGroupService public class AgeGroupService : IAgeGroupService
{ {

View File

@@ -1,6 +1,6 @@
using API.Models.Internal.Altersgruppen; using API.Models.Internal.Altersgruppen;
namespace API.Repository.AgeGroup namespace API.Repository.AgeGroupRepo
{ {
public interface IAgeGroupService public interface IAgeGroupService
{ {

View File

@@ -0,0 +1,13 @@
using API.Models.Internal.User;
namespace API.Repository.RegistrationKeyRepo;
public interface IRegistrationKeyService
{
public Task<List<RegistrationKey>> GetAllAsync();
public Task<RegistrationKey?> GetAsync(string id);
public Task<RegistrationKey> CreateAsync(RegistrationKeyIngoing key);
public Task<RegistrationKey?> DeleteAsync(string id);
public Task<int> DeleteOldRegistrationKeysAsync(int x);
}

View File

@@ -0,0 +1,76 @@
using API.Database;
using API.Models.Internal.User;
using Microsoft.EntityFrameworkCore;
namespace API.Repository.RegistrationKeyRepo;
public class RegistrationKeyService : IRegistrationKeyService
{
private ApplicationDbContext _context;
public RegistrationKeyService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<RegistrationKey>> GetAllAsync()
{
var allKeys = await _context.RegistrationKeys.ToListAsync();
return allKeys;
}
public async Task<RegistrationKey?> GetAsync(string id)
{
return await _context.RegistrationKeys.FindAsync(id);
}
public async Task<RegistrationKey> CreateAsync(RegistrationKeyIngoing key)
{
var internalKey = key.ToInternalFromIngoing();
await _context.RegistrationKeys.AddAsync(internalKey);
await _context.SaveChangesAsync();
return internalKey;
}
public async Task<RegistrationKey?> DeleteAsync(string id)
{
var key = await _context.RegistrationKeys.FirstOrDefaultAsync(x => x.Id == id);
if (key == null)
{
return null;
}
_context.RegistrationKeys.Remove(key);
await _context.SaveChangesAsync();
return key;
}
public async Task<int> DeleteOldRegistrationKeysAsync(int x)
{
if (x <= 0)
{
return 0;
}
var cutoff = DateTime.UtcNow.AddDays(-x);
var oldKeys = await _context.RegistrationKeys
.Where(k => k.Created < cutoff)
.ToListAsync();
if (oldKeys.Count == 0)
{
return 0;
}
_context.RegistrationKeys.RemoveRange(oldKeys);
await _context.SaveChangesAsync();
return oldKeys.Count;
}
}

View File

@@ -0,0 +1,36 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using API.Models.Outgoing;
using Microsoft.AspNetCore.Identity.UI.Services;
namespace API.Services;
public class PlunkEmailSender(HttpClient httpClient, string plunkSecretKey) : IEmailSender
{
public async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
var requestBody = new PlunkEmailDto()
{
To = email,
Subject = subject,
Body = htmlMessage
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(requestBody),
Encoding.UTF8,
"application/json"
);
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", plunkSecretKey);
var response = await httpClient.PostAsync("https://api.useplunk.com/v1/send", jsonContent);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new Exception($"Fehler beim Senden der E-Mail über Plunk: {error}");
}
}
}

Binary file not shown.

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { useTheme } from 'vuetify'; import { useTheme } from 'vuetify';
import { Visibility, routes } from './plugins/routesLayout'; import { Visibility, routes } from './plugins/routesLayout';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -25,6 +25,25 @@ onMounted(() => {
); );
showDrawer.value = localStorage.getItem('drawer')?.startsWith('Y') || false; showDrawer.value = localStorage.getItem('drawer')?.startsWith('Y') || false;
}); });
function changeWebsiteTitle(path: string) {
// Pfad als Parameter
let currentPageInfo = routes.find((x) => x.path === path);
if (currentPageInfo) {
document.title = 'Judoteam - Stadtlohn | ' + currentPageInfo.name;
} else {
document.title = 'Judoteam - Stadtlohn';
}
}
// Beobachte die Route auf Änderungen
watch(
() => route.path,
(newPath) => {
changeWebsiteTitle(newPath);
},
{ immediate: true },
);
</script> </script>
<template> <template>
@@ -40,7 +59,10 @@ onMounted(() => {
/> />
</template> </template>
<template v-slot:prepend> <template v-slot:prepend>
<v-app-bar-nav-icon @click="toggleDrawer()"></v-app-bar-nav-icon> <v-app-bar-nav-icon
v-tooltip="!showDrawer ? 'Menü öffnen' : 'Menü schließen'"
@click="toggleDrawer()"
></v-app-bar-nav-icon>
</template> </template>
<v-app-bar-title class="title" @click="$router.push({ name: 'Home' })" <v-app-bar-title class="title" @click="$router.push({ name: 'Home' })"
@@ -49,7 +71,7 @@ onMounted(() => {
<v-tooltip v-if="!$vuetify.display.mobile"> <v-tooltip v-if="!$vuetify.display.mobile">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn icon v-bind="props"> <v-btn icon v-bind="props" to="/login">
<v-icon>mdi-account</v-icon> <v-icon>mdi-account</v-icon>
</v-btn> </v-btn>
</template> </template>
@@ -87,6 +109,21 @@ onMounted(() => {
<v-main> <v-main>
<router-view></router-view> <router-view></router-view>
<v-footer
class="d-flex align-center justify-center ga-2 flex-wrap flex-grow-1 py-3"
>
<v-btn
v-for="link in routes.filter((x) => x.visible === Visibility.Footer)"
:key="link.path"
:to="link.path"
:text="link.name"
variant="text"
rounded
></v-btn>
<div class="flex-1-0-100 text-center mt-2">
{{ new Date().getFullYear() }} <strong>Judoteam - Stadtlohn</strong>
</div>
</v-footer>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>

View File

@@ -24,6 +24,6 @@ const props = defineProps({
padding: 2px; padding: 2px;
padding-right: 10px; padding-right: 10px;
padding-left: 10px; padding-left: 10px;
border-radius: 0px 0px 8px 8px; border-radius: 0px 0px var(--default-radius) var(--default-radius);
} }
</style> </style>

View File

@@ -1,3 +1,8 @@
:root{
--default-radius: 7px;
--red-color: #b62b2b
}
.title { .title {
font-weight: 600; font-weight: 600;
} }

View File

@@ -1,11 +1,15 @@
import Home from '@/routes/Home.vue' import Home from '@/routes/Home.vue'
import NotFound from '@/routes/404NotFound.vue' import NotFound from '@/routes/404NotFound.vue'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import Login from '@/routes/authentication/Login.vue';
export enum Visibility { export enum Visibility {
Hidden, Hidden,
Authorized, Authenticated,
Public Unauthenticated,
Authorized,
Public,
Footer,
} }
@@ -14,29 +18,49 @@ export interface LayoutRoute {
name: string, name: string,
description: string, description: string,
icon: string, icon: string,
disableFooter?: boolean,
visible: Visibility, visible: Visibility,
meta: RouteRecordRaw meta?: RouteRecordRaw
} }
export const routes: LayoutRoute[] = [ export const routes: LayoutRoute[] = [
{ {
path: "/", path: "/",
name: "Startseite", name: "Startseite",
description: "Übersicht der Anwendung", description: "Übersicht der Anwendung",
icon: "mdi-home", icon: "mdi-home",
visible: Visibility.Public, visible: Visibility.Public,
meta: { meta: {
name: 'Home', name: 'Home',
path: '/', path: '/',
component: Home component: Home
}
},
{
path: "/notFound",
name: "Nicht Verfügbar",
description: "Diese Seite wurde nicht gefunden",
icon: "mdi-information-outline",
visible: Visibility.Hidden,
meta: { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
} }
},
{
path: "/login",
name: "Login",
description: "Logge dich ein",
icon: "mdi-login",
visible: Visibility.Hidden,
meta: {
path: "/login",
name: 'Login',
component: Login
}
},
{
path: "/impressum",
name: "Impressum",
description: "Impressum der Anwendung",
icon: "mdi-file-document",
visible: Visibility.Footer
},
{
path: "/notFound",
name: "Nicht Gefunden",
description: "Diese Seite wurde nicht gefunden",
icon: "mdi-information-outline",
visible: Visibility.Hidden,
meta: { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
}
] ]

View File

@@ -1,9 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { routes } from '@/plugins/routesLayout' import { routes } from '@/plugins/routesLayout'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: routes.map(x => x.meta) routes: routes.filter(x => x.meta !== undefined).map(x => x.meta) as RouteRecordRaw[]
}) })
export default router export default router

View File

@@ -1,7 +1,17 @@
<script setup lang="ts"></script> <script setup lang="ts"></script>
<template> <template>
<h1>404</h1> <v-container fluid class="fill-height d-flex flex-column justify-center align-center">
<h1 class="error-title text-h1 font-weight-bold">404</h1>
<p class="error-message text-h5">Page not found</p>
<router-link
to="/"
class="text-blue text-decoration-none font-weight-medium mt-5"
>
Zurück zur Startseite
</router-link>
</v-container>
</template> </template>
<style scoped> <style scoped>
@@ -18,4 +28,7 @@
opacity: 0.85; opacity: 0.85;
color: #fff; color: #fff;
} }
a:hover {
text-decoration: underline !important;
}
</style> </style>

View File

@@ -14,7 +14,6 @@ const tabItems = [
<template> <template>
<v-container fluid> <v-container fluid>
<v-img class="rounded-lg" height="40vh" src="/static/images/startPage.png" cover /> <v-img class="rounded-lg" height="40vh" src="/static/images/startPage.png" cover />
<main> <main>
<v-container <v-container
id="main" id="main"

View File

@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<v-container class="">
<h1>Potenzieller Login</h1>
</v-container>
</template>
<style scoped></style>

190
README.md
View File

@@ -1 +1,189 @@
# JudoWebsurf # JudoWeb
Eine moderne Webanwendung für das Judoteam Stadtlohn - mit Vereinspräsentation und Verwaltungssystem.
## Projektbeschreibung
JudoWeb ist eine Full-Stack-Webanwendung, die zwei Hauptzwecke erfüllt:
- **Öffentliche Vereinswebsite**: Präsentation des Judoclubs mit Informationen zu Judo, Vereinswerten (Respekt, Disziplin, Selbstbehauptung) und Veranstaltungen
- **Verwaltungssystem**: Benutzerverwaltung, Registrierung und Altersgruppenmanagement
### Features
- Responsive Homepage mit Bildkarussell (Würfe, Haltetechniken, Stabilitätstraining)
- Veranstaltungsübersicht (Nikolausturnier, Wettkämpfe, Kinoabende)
- Benutzerauthentifizierung mit Registrierungsschlüsseln
- Altersgruppenmanagement
- Dark/Light Theme-Umschaltung
- Mobile-optimiertes Design
## Technologie-Stack
### Backend
- **.NET 9.0** mit ASP.NET Core Web API
- **Entity Framework Core 9** - ORM
- **ASP.NET Core Identity** - Authentifizierung
- **Swagger/Swashbuckle** - API-Dokumentation
### Frontend
- **Vue.js 3** mit TypeScript
- **Vite** - Build-Tool und Entwicklungsserver
- **Vuetify 3** - Material Design Komponenten
- **Vue Router** - Client-seitiges Routing
### Datenbank
- **SQLite** (Entwicklung)
- **PostgreSQL** (Produktion)
### Zusätzliche Services
- **Plunk** - E-Mail-Versand
## Projektstruktur
```
JudoWeb/
├── API/ # Backend (.NET)
│ ├── Controllers/ # API-Endpunkte
│ │ ├── AgeGroupController.cs
│ │ ├── AuthController.cs
│ │ └── RegistrationKeyController.cs
│ ├── Models/
│ │ ├── Internal/ # Domain-Modelle
│ │ └── Outgoing/ # DTOs
│ ├── Database/ # EF Core Kontext
│ ├── Repository/ # Datenzugriff
│ ├── Services/ # Business-Logik
│ └── Migrations/ # Datenbank-Migrationen
├── GUI/ # Frontend (Vue.js)
│ ├── src/
│ │ ├── routes/ # Seiten-Komponenten
│ │ ├── components/ # Wiederverwendbare Komponenten
│ │ ├── plugins/ # Vue-Plugins
│ │ └── router/ # Router-Konfiguration
│ └── public/static/ # Statische Assets
└── JudoWeb.sln # Visual Studio Solution
```
## Installation & Setup
### Voraussetzungen
- [.NET 9.0 SDK](https://dotnet.microsoft.com/download)
- [Node.js](https://nodejs.org/) (LTS-Version empfohlen)
- Optional: [Visual Studio 2022](https://visualstudio.microsoft.com/) oder [VS Code](https://code.visualstudio.com/)
### Frontend einrichten
```bash
cd GUI
npm install
```
### Backend einrichten
```bash
cd API
dotnet restore
```
## Entwicklung starten
### Frontend (mit Hot-Reload)
```bash
cd GUI
npm run dev
```
Der Entwicklungsserver startet unter `http://localhost:5173`
### Backend
```bash
cd API
dotnet run
```
Die API ist verfügbar unter:
- HTTP: `http://localhost:5246`
- HTTPS: `https://localhost:7248`
- Swagger UI: `https://localhost:7248/swagger`
## Verfügbare Scripts
### Frontend (GUI/)
| Befehl | Beschreibung |
|--------|--------------|
| `npm run dev` | Startet Entwicklungsserver |
| `npm run build` | Erstellt Produktions-Build |
| `npm run test:unit` | Führt Unit-Tests aus |
| `npm run type-check` | TypeScript-Typprüfung |
| `npm run format` | Code-Formatierung mit Prettier |
### Backend (API/)
| Befehl | Beschreibung |
|--------|--------------|
| `dotnet run` | Startet Entwicklungsserver |
| `dotnet build` | Kompiliert das Projekt |
| `dotnet publish -c Release` | Erstellt Produktions-Build |
| `dotnet ef database update` | Wendet Migrationen an |
## Datenbank-Konfiguration
Die Anwendung wählt automatisch die Datenbank basierend auf der Konfiguration:
1. **PostgreSQL** (Produktion): Wenn `PostgresConnection` in `appsettings.json` oder als Umgebungsvariable gesetzt ist
2. **SQLite** (Entwicklung): Standardmäßig wird `app.db` verwendet
Datenbank-Migrationen werden beim Anwendungsstart automatisch ausgeführt.
### Beispiel PostgreSQL-Konfiguration
```json
{
"ConnectionStrings": {
"PostgresConnection": "Host=localhost;Database=judoweb;Username=user;Password=pass"
}
}
```
## API-Endpunkte
| Endpunkt | Beschreibung |
|----------|--------------|
| `GET /api/ageGroups` | Alle Altersgruppen abrufen |
| `POST /api/ageGroups` | Neue Altersgruppe erstellen |
| `PUT /api/ageGroups/{id}` | Altersgruppe aktualisieren |
| `DELETE /api/ageGroups/{id}` | Altersgruppe löschen |
| `POST /api/account/register` | Benutzer registrieren |
| `POST /api/account/login` | Benutzer anmelden |
| `GET /api/registrationKey` | Registrierungsschlüssel abrufen |
Vollständige API-Dokumentation ist über Swagger UI verfügbar.
## Produktion
### Frontend bauen
```bash
cd GUI
npm run build
```
Das Build wird in `../wwwroot/` ausgegeben und vom Backend als statische Dateien serviert.
### Backend publizieren
```bash
cd API
dotnet publish -c Release -o ./publish
```
## Lizenz
Dieses Projekt ist proprietär und gehört dem Judoteam Stadtlohn.