groups
8b198c94543d036aff2037d4a4aff634c34e64aa
21 files changed
src/GitServer/Data/AppDbContext.cssrc/GitServer/Data/Migrations/20260726054052_AddGroups.Designer.cssrc/GitServer/Data/Migrations/20260726054052_AddGroups.cssrc/GitServer/Data/Migrations/AppDbContextModelSnapshot.cssrc/GitServer/Localization/en.jsonsrc/GitServer/Localization/nl.jsonsrc/GitServer/Middleware/GitAuthMiddleware.cssrc/GitServer/Models/AppUser.cssrc/GitServer/Models/Group.cssrc/GitServer/Models/GroupMember.cssrc/GitServer/Models/RepositoryAccess.cssrc/GitServer/Services/RepositoryService.cssrc/GitServer/wwwroot/Repo/Collaborators.cshtmlsrc/GitServer/wwwroot/Repo/Collaborators.cshtml.cssrc/GitServer/wwwroot/Repo/Issues/Detail.cshtml.cssrc/GitServer/wwwroot/Shared/_UserNav.cshtmlsrc/GitServer/wwwroot/User/GroupDetail.cshtmlsrc/GitServer/wwwroot/User/GroupDetail.cshtml.cssrc/GitServer/wwwroot/User/Groups.cshtmlsrc/GitServer/wwwroot/User/Groups.cshtml.cssrc/GitServer/wwwroot/css/components.css
diff --git a/src/GitServer/Data/AppDbContext.cs b/src/GitServer/Data/AppDbContext.cs
index 613f3f6..5035e62 100644
--- a/src/GitServer/Data/AppDbContext.cs
+++ b/src/GitServer/Data/AppDbContext.cs
@@ -11,6 +11,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbCo
public DbSet<Issue> Issues => Set<Issue>();
public DbSet<IssueComment> IssueComments => Set<IssueComment>();
public DbSet<BlockedEmailPattern> BlockedEmailPatterns => Set<BlockedEmailPattern>();
+ public DbSet<Group> Groups => Set<Group>();
+ public DbSet<GroupMember> GroupMembers => Set<GroupMember>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -27,7 +29,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbCo
builder.Entity<RepositoryAccess>(e =>
{
- e.HasIndex(a => new { a.RepositoryId, a.UserId }).IsUnique();
+ e.HasIndex(a => new { a.RepositoryId, a.UserId }).IsUnique().HasFilter("[UserId] IS NOT NULL");
+ e.HasIndex(a => new { a.RepositoryId, a.GroupId }).IsUnique().HasFilter("[GroupId] IS NOT NULL");
e.HasOne(a => a.Repository)
.WithMany(r => r.Accesses)
.HasForeignKey(a => a.RepositoryId)
@@ -36,6 +39,32 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbCo
.WithMany(u => u.RepositoryAccesses)
.HasForeignKey(a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(a => a.Group)
+ .WithMany(g => g.Accesses)
+ .HasForeignKey(a => a.GroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity<Group>(e =>
+ {
+ e.HasIndex(g => new { g.OwnerId, g.Name }).IsUnique();
+ e.HasOne(g => g.Owner)
+ .WithMany(u => u.Groups)
+ .HasForeignKey(g => g.OwnerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity<GroupMember>(e =>
+ {
+ e.HasIndex(m => new { m.GroupId, m.UserId }).IsUnique();
+ e.HasOne(m => m.Group)
+ .WithMany(g => g.Members)
+ .HasForeignKey(m => m.GroupId)
+ .OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(m => m.User)
+ .WithMany()
+ .HasForeignKey(m => m.UserId)
+ .OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<Issue>(e =>
diff --git a/src/GitServer/Data/Migrations/20260726054052_AddGroups.Designer.cs b/src/GitServer/Data/Migrations/20260726054052_AddGroups.Designer.cs
new file mode 100644
index 0000000..73463ae
--- /dev/null
+++ b/src/GitServer/Data/Migrations/20260726054052_AddGroups.Designer.cs
@@ -0,0 +1,638 @@
+// <auto-generated />
+using System;
+using GitServer.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace GitServer.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260726054052_AddGroups")]
+ partial class AddGroups
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("GitServer.Models.AppUser", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AvatarUrl")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Bio")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CompanyName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Country")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DisplayName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsAdmin")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastLoginAt")
+ .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>("PreferredLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("TimeZoneId")
+ .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("GitServer.Models.BlockedEmailPattern", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Pattern")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("BlockedEmailPatterns");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Group", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("GitServer.Models.GroupMember", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("GroupId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("GroupId", "UserId")
+ .IsUnique();
+
+ b.ToTable("GroupMembers");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AuthorId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Body")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsClosed")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("RepositoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Title")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("RepositoryId");
+
+ b.ToTable("Issues");
+ });
+
+ modelBuilder.Entity("GitServer.Models.IssueComment", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AuthorId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Body")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("IssueId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("IssueId");
+
+ b.ToTable("IssueComments");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DefaultBranch")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Description")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsPrivate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Repositories");
+ });
+
+ modelBuilder.Entity("GitServer.Models.RepositoryAccess", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("GroupId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Level")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("RepositoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("RepositoryId", "GroupId")
+ .IsUnique()
+ .HasFilter("[GroupId] IS NOT NULL");
+
+ b.HasIndex("RepositoryId", "UserId")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("RepositoryAccesses");
+ });
+
+ 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("GitServer.Models.Group", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Owner")
+ .WithMany("Groups")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("GitServer.Models.GroupMember", b =>
+ {
+ b.HasOne("GitServer.Models.Group", "Group")
+ .WithMany("Members")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.AppUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Group");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.Repository", "Repository")
+ .WithMany("Issues")
+ .HasForeignKey("RepositoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("Repository");
+ });
+
+ modelBuilder.Entity("GitServer.Models.IssueComment", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.Issue", "Issue")
+ .WithMany("Comments")
+ .HasForeignKey("IssueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("Issue");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Owner")
+ .WithMany("Repositories")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("GitServer.Models.RepositoryAccess", b =>
+ {
+ b.HasOne("GitServer.Models.Group", "Group")
+ .WithMany("Accesses")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("GitServer.Models.Repository", "Repository")
+ .WithMany("Accesses")
+ .HasForeignKey("RepositoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.AppUser", "User")
+ .WithMany("RepositoryAccesses")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Group");
+
+ b.Navigation("Repository");
+
+ b.Navigation("User");
+ });
+
+ 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("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", 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("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GitServer.Models.AppUser", b =>
+ {
+ b.Navigation("Groups");
+
+ b.Navigation("Repositories");
+
+ b.Navigation("RepositoryAccesses");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Group", b =>
+ {
+ b.Navigation("Accesses");
+
+ b.Navigation("Members");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.Navigation("Comments");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.Navigation("Accesses");
+
+ b.Navigation("Issues");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/GitServer/Data/Migrations/20260726054052_AddGroups.cs b/src/GitServer/Data/Migrations/20260726054052_AddGroups.cs
new file mode 100644
index 0000000..19567cc
--- /dev/null
+++ b/src/GitServer/Data/Migrations/20260726054052_AddGroups.cs
@@ -0,0 +1,170 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GitServer.Data.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddGroups : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_UserId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.AlterColumn<string>(
+ name: "UserId",
+ table: "RepositoryAccesses",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "TEXT");
+
+ migrationBuilder.AddColumn<int>(
+ name: "GroupId",
+ table: "RepositoryAccesses",
+ type: "INTEGER",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "Groups",
+ columns: table => new
+ {
+ Id = table.Column<int>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Name = table.Column<string>(type: "TEXT", nullable: false),
+ OwnerId = table.Column<string>(type: "TEXT", nullable: false),
+ CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Groups", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Groups_AspNetUsers_OwnerId",
+ column: x => x.OwnerId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "GroupMembers",
+ columns: table => new
+ {
+ Id = table.Column<int>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ GroupId = table.Column<int>(type: "INTEGER", nullable: false),
+ UserId = table.Column<string>(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_GroupMembers", x => x.Id);
+ table.ForeignKey(
+ name: "FK_GroupMembers_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ table.ForeignKey(
+ name: "FK_GroupMembers_Groups_GroupId",
+ column: x => x.GroupId,
+ principalTable: "Groups",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RepositoryAccesses_GroupId",
+ table: "RepositoryAccesses",
+ column: "GroupId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_GroupId",
+ table: "RepositoryAccesses",
+ columns: new[] { "RepositoryId", "GroupId" },
+ unique: true,
+ filter: "[GroupId] IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_UserId",
+ table: "RepositoryAccesses",
+ columns: new[] { "RepositoryId", "UserId" },
+ unique: true,
+ filter: "[UserId] IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_GroupMembers_GroupId_UserId",
+ table: "GroupMembers",
+ columns: new[] { "GroupId", "UserId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_GroupMembers_UserId",
+ table: "GroupMembers",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Groups_OwnerId_Name",
+ table: "Groups",
+ columns: new[] { "OwnerId", "Name" },
+ unique: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_RepositoryAccesses_Groups_GroupId",
+ table: "RepositoryAccesses",
+ column: "GroupId",
+ principalTable: "Groups",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_RepositoryAccesses_Groups_GroupId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.DropTable(
+ name: "GroupMembers");
+
+ migrationBuilder.DropTable(
+ name: "Groups");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RepositoryAccesses_GroupId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_GroupId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_UserId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.DropColumn(
+ name: "GroupId",
+ table: "RepositoryAccesses");
+
+ migrationBuilder.AlterColumn<string>(
+ name: "UserId",
+ table: "RepositoryAccesses",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: "",
+ oldClrType: typeof(string),
+ oldType: "TEXT",
+ oldNullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RepositoryAccesses_RepositoryId_UserId",
+ table: "RepositoryAccesses",
+ columns: new[] { "RepositoryId", "UserId" },
+ unique: true);
+ }
+ }
+}
diff --git a/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs b/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
index 7676251..41c52a5 100644
--- a/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -130,6 +130,54 @@ namespace GitServer.Data.Migrations
b.ToTable("BlockedEmailPatterns");
});
+ modelBuilder.Entity("GitServer.Models.Group", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("GitServer.Models.GroupMember", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("GroupId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("GroupId", "UserId")
+ .IsUnique();
+
+ b.ToTable("GroupMembers");
+ });
+
modelBuilder.Entity("GitServer.Models.Issue", b =>
{
b.Property<int>("Id")
@@ -242,6 +290,9 @@ namespace GitServer.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
+ b.Property<int?>("GroupId")
+ .HasColumnType("INTEGER");
+
b.Property<int>("Level")
.HasColumnType("INTEGER");
@@ -249,15 +300,21 @@ namespace GitServer.Data.Migrations
.HasColumnType("INTEGER");
b.Property<string>("UserId")
- .IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
+ b.HasIndex("GroupId");
+
b.HasIndex("UserId");
+ b.HasIndex("RepositoryId", "GroupId")
+ .IsUnique()
+ .HasFilter("[GroupId] IS NOT NULL");
+
b.HasIndex("RepositoryId", "UserId")
- .IsUnique();
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
b.ToTable("RepositoryAccesses");
});
@@ -390,6 +447,36 @@ namespace GitServer.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
+ modelBuilder.Entity("GitServer.Models.Group", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Owner")
+ .WithMany("Groups")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("GitServer.Models.GroupMember", b =>
+ {
+ b.HasOne("GitServer.Models.Group", "Group")
+ .WithMany("Members")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.AppUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Group");
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("GitServer.Models.Issue", b =>
{
b.HasOne("GitServer.Models.AppUser", "Author")
@@ -441,6 +528,11 @@ namespace GitServer.Data.Migrations
modelBuilder.Entity("GitServer.Models.RepositoryAccess", b =>
{
+ b.HasOne("GitServer.Models.Group", "Group")
+ .WithMany("Accesses")
+ .HasForeignKey("GroupId")
+ .OnDelete(DeleteBehavior.Cascade);
+
b.HasOne("GitServer.Models.Repository", "Repository")
.WithMany("Accesses")
.HasForeignKey("RepositoryId")
@@ -450,8 +542,9 @@ namespace GitServer.Data.Migrations
b.HasOne("GitServer.Models.AppUser", "User")
.WithMany("RepositoryAccesses")
.HasForeignKey("UserId")
- .OnDelete(DeleteBehavior.Cascade)
- .IsRequired();
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Group");
b.Navigation("Repository");
@@ -511,11 +604,20 @@ namespace GitServer.Data.Migrations
modelBuilder.Entity("GitServer.Models.AppUser", b =>
{
+ b.Navigation("Groups");
+
b.Navigation("Repositories");
b.Navigation("RepositoryAccesses");
});
+ modelBuilder.Entity("GitServer.Models.Group", b =>
+ {
+ b.Navigation("Accesses");
+
+ b.Navigation("Members");
+ });
+
modelBuilder.Entity("GitServer.Models.Issue", b =>
{
b.Navigation("Comments");
diff --git a/src/GitServer/Localization/en.json b/src/GitServer/Localization/en.json
index 8950ddb..5932ee4 100644
--- a/src/GitServer/Localization/en.json
+++ b/src/GitServer/Localization/en.json
@@ -4,6 +4,7 @@
"nav_explore": "Explore",
"nav_new": "+ New",
+ "nav_groups": "Groups",
"nav_logout": "Sign out",
"nav_login": "Sign in",
"nav_register": "Register",
@@ -123,6 +124,27 @@
"settings_collaborator_search_placeholder": "Search by username, email or display name",
"confirm_delete_user": "Delete user {0}?",
+ "group_label": "Group",
+ "settings_add_group": "Add group",
+ "error_group_not_found": "Group not found.",
+ "success_group_added": "Group added.",
+ "groups_hint": "Create groups of users so you can grant them access to a repository all at once.",
+ "groups_empty": "No groups yet.",
+ "group_name_label": "Group name",
+ "group_name_placeholder": "Group name",
+ "group_create": "Create group",
+ "group_members_count": "Members",
+ "group_manage": "Manage",
+ "group_members_hint": "Manage the members of this group.",
+ "group_no_members": "No members yet.",
+ "group_add_member": "Add member",
+ "group_delete": "Delete group",
+ "confirm_delete_group": "Delete group {0}?",
+ "error_group_name_required": "Enter a group name.",
+ "error_group_name_taken": "You already have a group with this name.",
+ "error_group_member_is_owner": "You cannot add yourself as a member.",
+ "success_group_member_added": "Member added.",
+
"issues_open": "Open",
"issues_closed": "Closed",
"issues_new": "New issue",
diff --git a/src/GitServer/Localization/nl.json b/src/GitServer/Localization/nl.json
index 91ae4ea..25b705e 100644
--- a/src/GitServer/Localization/nl.json
+++ b/src/GitServer/Localization/nl.json
@@ -4,6 +4,7 @@
"nav_explore": "Explore",
"nav_new": "+ Nieuw",
+ "nav_groups": "Groepen",
"nav_logout": "Uitloggen",
"nav_login": "Inloggen",
"nav_register": "Registreren",
@@ -123,6 +124,27 @@
"settings_collaborator_search_placeholder": "Zoek op gebruikersnaam, e-mail of weergavenaam",
"confirm_delete_user": "Gebruiker {0} verwijderen?",
+ "group_label": "Groep",
+ "settings_add_group": "Groep toevoegen",
+ "error_group_not_found": "Groep niet gevonden.",
+ "success_group_added": "Groep toegevoegd.",
+ "groups_hint": "Maak groepen van gebruikers aan om ze in één keer toegang te geven tot een repository.",
+ "groups_empty": "Nog geen groepen.",
+ "group_name_label": "Groepsnaam",
+ "group_name_placeholder": "Naam van de groep",
+ "group_create": "Groep aanmaken",
+ "group_members_count": "Leden",
+ "group_manage": "Beheren",
+ "group_members_hint": "Beheer de leden van deze groep.",
+ "group_no_members": "Nog geen leden.",
+ "group_add_member": "Lid toevoegen",
+ "group_delete": "Groep verwijderen",
+ "confirm_delete_group": "Groep {0} verwijderen?",
+ "error_group_name_required": "Voer een groepsnaam in.",
+ "error_group_name_taken": "Je hebt al een groep met deze naam.",
+ "error_group_member_is_owner": "Je kunt jezelf niet als lid toevoegen.",
+ "success_group_member_added": "Lid toegevoegd.",
+
"issues_open": "Open",
"issues_closed": "Gesloten",
"issues_new": "Nieuw issue",
diff --git a/src/GitServer/Middleware/GitAuthMiddleware.cs b/src/GitServer/Middleware/GitAuthMiddleware.cs
index 3a85273..d0a84bf 100644
--- a/src/GitServer/Middleware/GitAuthMiddleware.cs
+++ b/src/GitServer/Middleware/GitAuthMiddleware.cs
@@ -131,13 +131,7 @@ public class GitAuthMiddleware(RequestDelegate next)
if (isPush)
{
- var canWrite = authedUser.Id == repo.OwnerId ||
- await db.RepositoryAccesses.AnyAsync(a =>
- a.RepositoryId == repo.Id &&
- a.UserId == authedUser.Id &&
- a.Level == Models.AccessLevel.Write);
-
- if (!canWrite)
+ if (!await repoService.CanWriteAsync(repo, authedUser.Id))
{
context.Response.StatusCode = 403;
return;
@@ -145,11 +139,7 @@ public class GitAuthMiddleware(RequestDelegate next)
}
else
{
- var canRead = authedUser.Id == repo.OwnerId ||
- await db.RepositoryAccesses.AnyAsync(a =>
- a.RepositoryId == repo.Id && a.UserId == authedUser.Id);
-
- if (!canRead)
+ if (!await repoService.CanReadAsync(repo, authedUser.Id))
{
context.Response.StatusCode = 403;
return;
diff --git a/src/GitServer/Models/AppUser.cs b/src/GitServer/Models/AppUser.cs
index f0879fb..6b04a3b 100644
--- a/src/GitServer/Models/AppUser.cs
+++ b/src/GitServer/Models/AppUser.cs
@@ -19,6 +19,7 @@ public class AppUser : IdentityUser
public ICollection<Repository> Repositories { get; set; } = new List<Repository>();
public ICollection<RepositoryAccess> RepositoryAccesses { get; set; } = new List<RepositoryAccess>();
+ public ICollection<Group> Groups { get; set; } = new List<Group>();
/// <summary>AvatarUrl if set, otherwise a Gravatar derived from Email.</summary>
[NotMapped]
diff --git a/src/GitServer/Models/Group.cs b/src/GitServer/Models/Group.cs
new file mode 100644
index 0000000..2267222
--- /dev/null
+++ b/src/GitServer/Models/Group.cs
@@ -0,0 +1,13 @@
+namespace GitServer.Models;
+
+public class Group
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public string OwnerId { get; set; } = "";
+ public AppUser Owner { get; set; } = null!;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public ICollection<GroupMember> Members { get; set; } = new List<GroupMember>();
+ public ICollection<RepositoryAccess> Accesses { get; set; } = new List<RepositoryAccess>();
+}
diff --git a/src/GitServer/Models/GroupMember.cs b/src/GitServer/Models/GroupMember.cs
new file mode 100644
index 0000000..e003abf
--- /dev/null
+++ b/src/GitServer/Models/GroupMember.cs
@@ -0,0 +1,10 @@
+namespace GitServer.Models;
+
+public class GroupMember
+{
+ public int Id { get; set; }
+ public int GroupId { get; set; }
+ public Group Group { get; set; } = null!;
+ public string UserId { get; set; } = "";
+ public AppUser User { get; set; } = null!;
+}
diff --git a/src/GitServer/Models/RepositoryAccess.cs b/src/GitServer/Models/RepositoryAccess.cs
index caae11e..934341f 100644
--- a/src/GitServer/Models/RepositoryAccess.cs
+++ b/src/GitServer/Models/RepositoryAccess.cs
@@ -7,7 +7,9 @@ public class RepositoryAccess
public int Id { get; set; }
public int RepositoryId { get; set; }
public Repository Repository { get; set; } = null!;
- public string UserId { get; set; } = "";
- public AppUser User { get; set; } = null!;
+ public string? UserId { get; set; }
+ public AppUser? User { get; set; }
+ public int? GroupId { get; set; }
+ public Group? Group { get; set; }
public AccessLevel Level { get; set; }
}
diff --git a/src/GitServer/Services/RepositoryService.cs b/src/GitServer/Services/RepositoryService.cs
index c2a21ac..d6029b5 100644
--- a/src/GitServer/Services/RepositoryService.cs
+++ b/src/GitServer/Services/RepositoryService.cs
@@ -61,7 +61,8 @@ public class RepositoryService(AppDbContext db,
if (userId == null) return false;
if (repo.OwnerId == userId) return true;
return await _db.RepositoryAccesses
- .AnyAsync(a => a.RepositoryId == repo.Id && a.UserId == userId);
+ .AnyAsync(a => a.RepositoryId == repo.Id &&
+ (a.UserId == userId || (a.GroupId != null && a.Group!.Members.Any(m => m.UserId == userId))));
}
public async Task<bool> CanWriteAsync(Repository repo, string? userId)
@@ -69,7 +70,8 @@ public class RepositoryService(AppDbContext db,
if (userId == null) return false;
if (repo.OwnerId == userId) return true;
return await _db.RepositoryAccesses
- .AnyAsync(a => a.RepositoryId == repo.Id && a.UserId == userId && a.Level == AccessLevel.Write);
+ .AnyAsync(a => a.RepositoryId == repo.Id && a.Level == AccessLevel.Write &&
+ (a.UserId == userId || (a.GroupId != null && a.Group!.Members.Any(m => m.UserId == userId))));
}
public async Task<List<Repository>> GetPublicReposAsync(int skip = 0, int take = 20)
diff --git a/src/GitServer/wwwroot/Repo/Collaborators.cshtml b/src/GitServer/wwwroot/Repo/Collaborators.cshtml
index 35b3ce0..651dde1 100644
--- a/src/GitServer/wwwroot/Repo/Collaborators.cshtml
+++ b/src/GitServer/wwwroot/Repo/Collaborators.cshtml
@@ -37,16 +37,26 @@
{
<tr>
<td>
- <a href="/User/@access.User.UserName" class="collaborator-user">
- <img src="@access.User.EffectiveAvatarUrl" class="avatar-img avatar-sm" alt="" />
- <span>
- <strong>@access.User.UserName</strong>
- @if (!string.IsNullOrEmpty(access.User.DisplayName))
- {
- <span class="text-muted"> — @access.User.DisplayName</span>
- }
+ @if (access.User != null)
+ {
+ <a href="/User/@access.User.UserName" class="collaborator-user">
+ <img src="@access.User.EffectiveAvatarUrl" class="avatar-img avatar-sm" alt="" />
+ <span>
+ <strong>@access.User.UserName</strong>
+ @if (!string.IsNullOrEmpty(access.User.DisplayName))
+ {
+ <span class="text-muted"> — @access.User.DisplayName</span>
+ }
+ </span>
+ </a>
+ }
+ else if (access.Group != null)
+ {
+ <span class="collaborator-user">
+ <span class="badge badge-group">@L["group_label"]</span>
+ <strong>@access.Group.Name</strong>
</span>
- </a>
+ }
</td>
<td>
<span class="badge @(access.Level == AccessLevel.Write ? "badge-write" : "badge-read")">
@@ -86,6 +96,29 @@
</div>
<button type="submit" class="btn btn-primary">@L["settings_add_collaborator"]</button>
</form>
+
+ @if (Model.OwnGroups.Count > 0)
+ {
+ <form method="post" asp-page-handler="AddGroup" class="form-inline collaborator-add-form">
+ <div class="form-group">
+ <label for="groupId">@L["group_label"]</label>
+ <select asp-for="GroupId" id="groupId" class="form-control">
+ @foreach (var group in Model.OwnGroups)
+ {
+ <option value="@group.Id">@group.Name</option>
+ }
+ </select>
+ </div>
+ <div class="form-group">
+ <label for="groupLevel">@L["settings_collaborator_level"]</label>
+ <select asp-for="GroupLevel" id="groupLevel" class="form-control">
+ <option value="@AccessLevel.Read">@L["settings_access_read"]</option>
+ <option value="@AccessLevel.Write">@L["settings_access_write"]</option>
+ </select>
+ </div>
+ <button type="submit" class="btn btn-primary">@L["settings_add_group"]</button>
+ </form>
+ }
</section>
</div>
diff --git a/src/GitServer/wwwroot/Repo/Collaborators.cshtml.cs b/src/GitServer/wwwroot/Repo/Collaborators.cshtml.cs
index 84b892a..677d00f 100644
--- a/src/GitServer/wwwroot/Repo/Collaborators.cshtml.cs
+++ b/src/GitServer/wwwroot/Repo/Collaborators.cshtml.cs
@@ -22,9 +22,12 @@ public class CollaboratorsModel(
public string? Message { get; set; }
public bool IsError { get; set; }
public List<RepositoryAccess> Collaborators { get; set; } = new();
+ public List<Group> OwnGroups { get; set; } = new();
[BindProperty] public string? CollaboratorName { get; set; }
[BindProperty] public AccessLevel CollaboratorLevel { get; set; } = AccessLevel.Write;
+ [BindProperty] public int GroupId { get; set; }
+ [BindProperty] public AccessLevel GroupLevel { get; set; } = AccessLevel.Write;
private async Task<(Repository? repo, bool isOwner)> LoadAsync(string user, string repo)
{
@@ -43,8 +46,17 @@ public class CollaboratorsModel(
{
Collaborators = await db.RepositoryAccesses
.Include(a => a.User)
+ .Include(a => a.Group)
.Where(a => a.RepositoryId == repositoryId)
- .OrderBy(a => a.User.UserName)
+ .OrderBy(a => a.User != null ? a.User.UserName : a.Group!.Name)
+ .ToListAsync();
+ }
+
+ private async Task LoadOwnGroupsAsync(string ownerId)
+ {
+ OwnGroups = await db.Groups
+ .Where(g => g.OwnerId == ownerId)
+ .OrderBy(g => g.Name)
.ToListAsync();
}
@@ -55,6 +67,7 @@ public class CollaboratorsModel(
if (!isOwner) return Forbid();
await LoadCollaboratorsAsync(repoObj.Id);
+ await LoadOwnGroupsAsync(repoObj.OwnerId);
return Page();
}
@@ -88,6 +101,7 @@ public class CollaboratorsModel(
if (!isOwner) return Forbid();
await LoadCollaboratorsAsync(repoObj.Id);
+ await LoadOwnGroupsAsync(repoObj.OwnerId);
var name = CollaboratorName?.Trim();
if (string.IsNullOrEmpty(name))
@@ -132,6 +146,43 @@ public class CollaboratorsModel(
return RedirectToPage(new { user, repo });
}
+ public async Task<IActionResult> OnPostAddGroupAsync(string user, string repo)
+ {
+ var (repoObj, isOwner) = await LoadAsync(user, repo);
+ if (repoObj == null) return NotFound();
+ if (!isOwner) return Forbid();
+
+ await LoadCollaboratorsAsync(repoObj.Id);
+ await LoadOwnGroupsAsync(repoObj.OwnerId);
+
+ var group = OwnGroups.FirstOrDefault(g => g.Id == GroupId);
+ if (group == null)
+ {
+ Message = L["error_group_not_found"];
+ IsError = true;
+ return Page();
+ }
+
+ var existing = Collaborators.FirstOrDefault(a => a.GroupId == group.Id);
+ if (existing != null)
+ {
+ existing.Level = GroupLevel;
+ }
+ else
+ {
+ db.RepositoryAccesses.Add(new RepositoryAccess
+ {
+ RepositoryId = repoObj.Id,
+ GroupId = group.Id,
+ Level = GroupLevel,
+ });
+ }
+
+ await db.SaveChangesAsync();
+ Message = L["success_group_added"];
+ return RedirectToPage(new { user, repo });
+ }
+
public async Task<IActionResult> OnPostRemoveCollaboratorAsync(string user, string repo, int accessId)
{
var (repoObj, isOwner) = await LoadAsync(user, repo);
diff --git a/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml.cs b/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml.cs
index 803d351..b966f39 100644
--- a/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml.cs
+++ b/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml.cs
@@ -34,8 +34,7 @@ public class DetailModel(
.FirstOrDefaultAsync(i => i.RepositoryId == repoObj.Id && i.Id == id);
var userId = userManager.GetUserId(User);
- CanManage = userId != null && (repoObj.OwnerId == userId || Issue?.AuthorId == userId ||
- await db.RepositoryAccesses.AnyAsync(a => a.RepositoryId == repoObj.Id && a.UserId == userId && a.Level == AccessLevel.Write));
+ CanManage = userId != null && (Issue?.AuthorId == userId || await repos.CanWriteAsync(repoObj, userId));
return (repoObj, Issue);
}
diff --git a/src/GitServer/wwwroot/Shared/_UserNav.cshtml b/src/GitServer/wwwroot/Shared/_UserNav.cshtml
index 715335b..6d31e1d 100644
--- a/src/GitServer/wwwroot/Shared/_UserNav.cshtml
+++ b/src/GitServer/wwwroot/Shared/_UserNav.cshtml
@@ -7,6 +7,8 @@
{
<a class="repo-nav-link @(Model.ActiveTab == "new" ? "active" : "")"
href="/Repo/New">@L["nav_new"]</a>
+ <a class="repo-nav-link @(Model.ActiveTab == "groups" ? "active" : "")"
+ href="/User/Groups">@L["nav_groups"]</a>
<a class="repo-nav-link @(Model.ActiveTab == "profile" ? "active" : "")"
href="/User/Settings">@L["account_settings"]</a>
}
diff --git a/src/GitServer/wwwroot/User/GroupDetail.cshtml b/src/GitServer/wwwroot/User/GroupDetail.cshtml
new file mode 100644
index 0000000..31a4bdb
--- /dev/null
+++ b/src/GitServer/wwwroot/User/GroupDetail.cshtml
@@ -0,0 +1,137 @@
+@page "/User/GroupDetail/{id:int}"
+@model GitServer.wwwroot.User.GroupDetailModel
+@{
+ ViewData["Title"] = Model.Group?.Name ?? L["nav_groups"];
+}
+<div class="container">
+ @if (Model.CurrentUser != null)
+ {
+ @await Html.PartialAsync("_ProfileHeader", Model.CurrentUser)
+
+ @await Html.PartialAsync("_UserNav", (Model.CurrentUser.UserName!, "groups", true))
+ }
+
+ @if (!string.IsNullOrEmpty(Model.Message))
+ {
+ <div class="alert @(Model.IsError ? "alert-danger" : "alert-success")">@Model.Message</div>
+ }
+
+ <section class="settings-section">
+ <h2>@Model.Group!.Name</h2>
+ <p class="text-muted">@L["group_members_hint"]</p>
+
+ @if (Model.Members.Count > 0)
+ {
+ <table class="data-table collaborators-table">
+ <thead>
+ <tr>
+ <th>@L["settings_collaborator_user"]</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var member in Model.Members)
+ {
+ <tr>
+ <td>
+ <a href="/User/@member.User.UserName" class="collaborator-user">
+ <img src="@member.User.EffectiveAvatarUrl" class="avatar-img avatar-sm" alt="" />
+ <span>
+ <strong>@member.User.UserName</strong>
+ @if (!string.IsNullOrEmpty(member.User.DisplayName))
+ {
+ <span class="text-muted"> — @member.User.DisplayName</span>
+ }
+ </span>
+ </a>
+ </td>
+ <td>
+ <form method="post" asp-page-handler="RemoveMember">
+ <input type="hidden" name="memberId" value="@member.Id" />
+ <button type="submit" class="btn btn-danger btn-sm">@L["delete_button"]</button>
+ </form>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+ else
+ {
+ <p class="text-muted">@L["group_no_members"]</p>
+ }
+
+ <form method="post" asp-page-handler="AddMember" class="form-inline collaborator-add-form">
+ <div class="form-group collaborator-search">
+ <label for="memberName">@L["settings_collaborator_username"]</label>
+ <input asp-for="MemberName" id="memberName" type="text" class="form-control"
+ autocomplete="off" placeholder="@L["settings_collaborator_search_placeholder"]" />
+ <div id="memberResults" class="collaborator-results"></div>
+ </div>
+ <button type="submit" class="btn btn-primary">@L["group_add_member"]</button>
+ </form>
+ </section>
+
+ <section class="settings-section">
+ <h2>@L["settings_danger_zone"]</h2>
+ <form method="post" asp-page-handler="Delete"
+ onsubmit="return confirm('@L.Format("confirm_delete_group", Model.Group.Name)')">
+ <button type="submit" class="btn btn-danger">@L["group_delete"]</button>
+ </form>
+ </section>
+</div>
+
+@section Scripts {
+ <script>
+ (function () {
+ var input = document.getElementById('memberName');
+ var results = document.getElementById('memberResults');
+ var searchUrl = window.location.pathname + '?handler=SearchUsers';
+ var debounceTimer = null;
+ var currentRequest = null;
+
+ function hideResults() {
+ results.innerHTML = '';
+ results.classList.remove('open');
+ }
+
+ function renderResults(users) {
+ if (!users.length) { hideResults(); return; }
+ results.innerHTML = '';
+ users.forEach(function (u) {
+ var item = document.createElement('div');
+ item.className = 'collaborator-result-item';
+ var label = u.userName + (u.displayName ? ' — ' + u.displayName : '') +
+ (u.email ? ' (' + u.email + ')' : '');
+ item.textContent = label;
+ item.addEventListener('click', function () {
+ input.value = u.userName;
+ hideResults();
+ });
+ results.appendChild(item);
+ });
+ results.classList.add('open');
+ }
+
+ input.addEventListener('input', function () {
+ var q = input.value.trim();
+ clearTimeout(debounceTimer);
+ if (q.length < 2) { hideResults(); return; }
+
+ debounceTimer = setTimeout(function () {
+ if (currentRequest) currentRequest.abort();
+ var controller = new AbortController();
+ currentRequest = controller;
+ fetch(searchUrl + '&q=' + encodeURIComponent(q), { signal: controller.signal })
+ .then(function (r) { return r.json(); })
+ .then(renderResults)
+ .catch(function () { });
+ }, 200);
+ });
+
+ document.addEventListener('click', function (e) {
+ if (e.target !== input && !results.contains(e.target)) hideResults();
+ });
+ })();
+ </script>
+}
diff --git a/src/GitServer/wwwroot/User/GroupDetail.cshtml.cs b/src/GitServer/wwwroot/User/GroupDetail.cshtml.cs
new file mode 100644
index 0000000..94f923a
--- /dev/null
+++ b/src/GitServer/wwwroot/User/GroupDetail.cshtml.cs
@@ -0,0 +1,128 @@
+using GitServer.Data;
+using GitServer.Models;
+using GitServer.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+
+namespace GitServer.wwwroot.User;
+
+[Authorize]
+public class GroupDetailModel(AppDbContext db, UserManager<AppUser> userManager, LocalizationService L) : PageModel
+{
+ public AppUser? CurrentUser { get; set; }
+ public Group? Group { get; set; }
+ public List<GroupMember> Members { get; set; } = new();
+ public string? Message { get; set; }
+ public bool IsError { get; set; }
+
+ [BindProperty] public string? MemberName { get; set; }
+
+ private async Task<bool> LoadAsync(int id)
+ {
+ CurrentUser = await userManager.GetUserAsync(User);
+ if (CurrentUser == null) return false;
+
+ Group = await db.Groups.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == CurrentUser.Id);
+ if (Group == null) return false;
+
+ Members = await db.GroupMembers
+ .Include(m => m.User)
+ .Where(m => m.GroupId == id)
+ .OrderBy(m => m.User.UserName)
+ .ToListAsync();
+
+ return true;
+ }
+
+ public async Task<IActionResult> OnGetAsync(int id)
+ {
+ if (!await LoadAsync(id)) return NotFound();
+ return Page();
+ }
+
+ public async Task<IActionResult> OnGetSearchUsersAsync(int id, string? q)
+ {
+ if (!await LoadAsync(id)) return NotFound();
+
+ q = q?.Trim();
+ if (string.IsNullOrEmpty(q) || q.Length < 2) return new JsonResult(Array.Empty<object>());
+
+ var lower = q.ToLower();
+ var memberIds = Members.Select(m => m.UserId).ToHashSet();
+ var results = await db.Users
+ .Where(u => u.Id != CurrentUser!.Id && (
+ u.UserName!.ToLower().Contains(lower) ||
+ u.Email!.ToLower().Contains(lower) ||
+ u.DisplayName.ToLower().Contains(lower)))
+ .OrderBy(u => u.UserName)
+ .Take(10)
+ .Select(u => new { userName = u.UserName, displayName = u.DisplayName, email = u.Email })
+ .ToListAsync();
+
+ return new JsonResult(results);
+ }
+
+ public async Task<IActionResult> OnPostAddMemberAsync(int id)
+ {
+ if (!await LoadAsync(id)) return NotFound();
+
+ var name = MemberName?.Trim();
+ if (string.IsNullOrEmpty(name))
+ {
+ Message = L["error_collaborator_name_required"];
+ IsError = true;
+ return Page();
+ }
+
+ var member = await userManager.FindByNameAsync(name);
+ if (member == null)
+ {
+ Message = L["error_user_not_found"];
+ IsError = true;
+ return Page();
+ }
+
+ if (member.Id == CurrentUser!.Id)
+ {
+ Message = L["error_group_member_is_owner"];
+ IsError = true;
+ return Page();
+ }
+
+ if (!Members.Any(m => m.UserId == member.Id))
+ {
+ db.GroupMembers.Add(new GroupMember { GroupId = Group!.Id, UserId = member.Id });
+ await db.SaveChangesAsync();
+ }
+
+ Message = L["success_group_member_added"];
+ return RedirectToPage(new { id });
+ }
+
+ public async Task<IActionResult> OnPostRemoveMemberAsync(int id, int memberId)
+ {
+ if (!await LoadAsync(id)) return NotFound();
+
+ var member = await db.GroupMembers.FirstOrDefaultAsync(m => m.Id == memberId && m.GroupId == id);
+ if (member != null)
+ {
+ db.GroupMembers.Remove(member);
+ await db.SaveChangesAsync();
+ }
+
+ return RedirectToPage(new { id });
+ }
+
+ public async Task<IActionResult> OnPostDeleteAsync(int id)
+ {
+ if (!await LoadAsync(id)) return NotFound();
+
+ db.Groups.Remove(Group!);
+ await db.SaveChangesAsync();
+
+ return RedirectToPage("/User/Groups");
+ }
+}
diff --git a/src/GitServer/wwwroot/User/Groups.cshtml b/src/GitServer/wwwroot/User/Groups.cshtml
new file mode 100644
index 0000000..58992a6
--- /dev/null
+++ b/src/GitServer/wwwroot/User/Groups.cshtml
@@ -0,0 +1,59 @@
+@page "/User/Groups"
+@model GitServer.wwwroot.User.GroupsModel
+@{
+ ViewData["Title"] = L["nav_groups"];
+}
+<div class="container">
+ @if (Model.CurrentUser != null)
+ {
+ @await Html.PartialAsync("_ProfileHeader", Model.CurrentUser)
+
+ @await Html.PartialAsync("_UserNav", (Model.CurrentUser.UserName!, "groups", true))
+ }
+
+ @if (!string.IsNullOrEmpty(Model.Message))
+ {
+ <div class="alert @(Model.IsError ? "alert-danger" : "alert-success")">@Model.Message</div>
+ }
+
+ <section class="settings-section">
+ <h2>@L["nav_groups"]</h2>
+ <p class="text-muted">@L["groups_hint"]</p>
+
+ @if (Model.Groups.Count > 0)
+ {
+ <table class="data-table">
+ <thead>
+ <tr>
+ <th>@L["group_name_label"]</th>
+ <th>@L["group_members_count"]</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var group in Model.Groups)
+ {
+ <tr>
+ <td><a href="/User/GroupDetail/@group.Id">@group.Name</a></td>
+ <td>@group.Members.Count</td>
+ <td><a href="/User/GroupDetail/@group.Id" class="btn btn-secondary btn-sm">@L["group_manage"]</a></td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+ else
+ {
+ <p class="text-muted">@L["groups_empty"]</p>
+ }
+
+ <form method="post" asp-page-handler="Create" class="form-inline collaborator-add-form">
+ <div class="form-group">
+ <label for="newGroupName">@L["group_name_label"]</label>
+ <input asp-for="NewGroupName" id="newGroupName" type="text" class="form-control"
+ placeholder="@L["group_name_placeholder"]" />
+ </div>
+ <button type="submit" class="btn btn-primary">@L["group_create"]</button>
+ </form>
+ </section>
+</div>
diff --git a/src/GitServer/wwwroot/User/Groups.cshtml.cs b/src/GitServer/wwwroot/User/Groups.cshtml.cs
new file mode 100644
index 0000000..7701840
--- /dev/null
+++ b/src/GitServer/wwwroot/User/Groups.cshtml.cs
@@ -0,0 +1,65 @@
+using GitServer.Data;
+using GitServer.Models;
+using GitServer.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+
+namespace GitServer.wwwroot.User;
+
+[Authorize]
+public class GroupsModel(AppDbContext db, UserManager<AppUser> userManager, LocalizationService L) : PageModel
+{
+ public AppUser? CurrentUser { get; set; }
+ public List<Group> Groups { get; set; } = new();
+ public string? Message { get; set; }
+ public bool IsError { get; set; }
+
+ [BindProperty] public string NewGroupName { get; set; } = "";
+
+ private async Task LoadAsync()
+ {
+ CurrentUser = await userManager.GetUserAsync(User);
+ if (CurrentUser == null) return;
+
+ Groups = await db.Groups
+ .Include(g => g.Members)
+ .Where(g => g.OwnerId == CurrentUser.Id)
+ .OrderBy(g => g.Name)
+ .ToListAsync();
+ }
+
+ public async Task OnGetAsync()
+ {
+ await LoadAsync();
+ }
+
+ public async Task<IActionResult> OnPostCreateAsync()
+ {
+ await LoadAsync();
+ if (CurrentUser == null) return Challenge();
+
+ var name = NewGroupName?.Trim();
+ if (string.IsNullOrEmpty(name))
+ {
+ Message = L["error_group_name_required"];
+ IsError = true;
+ return Page();
+ }
+
+ if (Groups.Any(g => g.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
+ {
+ Message = L["error_group_name_taken"];
+ IsError = true;
+ return Page();
+ }
+
+ var group = new Group { Name = name, OwnerId = CurrentUser.Id };
+ db.Groups.Add(group);
+ await db.SaveChangesAsync();
+
+ return RedirectToPage("/User/GroupDetail", new { id = group.Id });
+ }
+}
diff --git a/src/GitServer/wwwroot/css/components.css b/src/GitServer/wwwroot/css/components.css
index 0798eef..5a9e648 100644
--- a/src/GitServer/wwwroot/css/components.css
+++ b/src/GitServer/wwwroot/css/components.css
@@ -354,6 +354,12 @@ textarea.form-control { resize: vertical; }
color: #3fb950;
}
+.badge-group {
+ background: rgba(88, 166, 255, 0.15);
+ color: #58a6ff;
+ margin-right: 0.4rem;
+}
+
.collaborator-add-form {
margin-top: 1.25rem;
align-items: flex-end;