diff --git a/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj b/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj
new file mode 100644
index 0000000..f14c4ed
--- /dev/null
+++ b/FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs
new file mode 100644
index 0000000..e9f8dfd
--- /dev/null
+++ b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs
@@ -0,0 +1,326 @@
+using FictionArchive.Service.UserService.Models.Database;
+using FictionArchive.Service.UserService.Services;
+using FictionArchive.Service.UserService.Services.AuthenticationClient;
+using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Xunit;
+
+namespace FictionArchive.Service.UserService.Tests;
+
+public class UserManagementServiceTests
+{
+ #region Helper Methods
+
+ private static UserServiceDbContext CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"UserManagementServiceTests-{Guid.NewGuid()}")
+ .Options;
+
+ return new UserServiceDbContext(options, NullLogger.Instance);
+ }
+
+ private static UserManagementService CreateService(
+ UserServiceDbContext dbContext,
+ IAuthenticationServiceClient authClient)
+ {
+ return new UserManagementService(
+ dbContext,
+ NullLogger.Instance,
+ authClient);
+ }
+
+ private static User CreateTestUser(string username, string email, int availableInvites = 5)
+ {
+ return new User
+ {
+ Username = username,
+ Email = email,
+ OAuthProviderId = Guid.NewGuid().ToString(),
+ Disabled = false,
+ AvailableInvites = availableInvites
+ };
+ }
+
+ #endregion
+
+ #region InviteUserAsync Tests
+
+ [Fact]
+ public async Task InviteUserAsync_WithValidInviter_CreatesUserAndDecrementsInvites()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
+ dbContext.Users.Add(inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" });
+ authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(true);
+
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Username.Should().Be("newuser");
+ result.Email.Should().Be("new@test.com");
+ result.InviterId.Should().Be(inviter.Id);
+ result.AvailableInvites.Should().Be(0);
+ inviter.AvailableInvites.Should().Be(2);
+
+ await authClient.Received(1).CreateUserAsync("newuser", "new@test.com", "newuser");
+ await authClient.Received(1).SendRecoveryEmailAsync(123);
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_WithNoAvailableInvites_ReturnsNull()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 0);
+ dbContext.Users.Add(inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
+
+ // Assert
+ result.Should().BeNull();
+ await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_WithDuplicateEmail_ReturnsNull()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var existingUser = CreateTestUser("existing", "existing@test.com");
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
+ dbContext.Users.AddRange(existingUser, inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "existing@test.com", "newuser");
+
+ // Assert
+ result.Should().BeNull();
+ await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ inviter.AvailableInvites.Should().Be(3); // Not decremented
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_WithDuplicateUsername_ReturnsNull()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var existingUser = CreateTestUser("existinguser", "existing@test.com");
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
+ dbContext.Users.AddRange(existingUser, inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "new@test.com", "existinguser");
+
+ // Assert
+ result.Should().BeNull();
+ await authClient.DidNotReceive().CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ inviter.AvailableInvites.Should().Be(3); // Not decremented
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_WhenAuthentikFails_ReturnsNull()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
+ dbContext.Users.Add(inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns((AuthentikUserResponse?)null);
+
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
+
+ // Assert
+ result.Should().BeNull();
+ await authClient.DidNotReceive().SendRecoveryEmailAsync(Arg.Any());
+
+ // Verify no user was added to the database
+ var usersInDb = await dbContext.Users.ToListAsync();
+ usersInDb.Should().HaveCount(1); // Only the inviter
+ inviter.AvailableInvites.Should().Be(3); // Not decremented
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_WhenRecoveryEmailFails_StillCreatesUser()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 3);
+ dbContext.Users.Add(inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new AuthentikUserResponse { Pk = 123, Uid = "authentik-uid-456" });
+ authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(false); // Email fails
+
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "new@test.com", "newuser");
+
+ // Assert - User should still be created despite email failure
+ result.Should().NotBeNull();
+ result!.Username.Should().Be("newuser");
+ inviter.AvailableInvites.Should().Be(2);
+
+ // Verify user was added to database
+ var usersInDb = await dbContext.Users.ToListAsync();
+ usersInDb.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task InviteUserAsync_SetsCorrectUserProperties()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var inviter = CreateTestUser("inviter", "inviter@test.com", availableInvites: 5);
+ dbContext.Users.Add(inviter);
+ await dbContext.SaveChangesAsync();
+
+ var authentikUid = "authentik-uid-789";
+ var authClient = Substitute.For();
+ authClient.CreateUserAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid });
+ authClient.SendRecoveryEmailAsync(Arg.Any()).Returns(true);
+
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.InviteUserAsync(inviter, "newuser@test.com", "newusername");
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Username.Should().Be("newusername");
+ result.Email.Should().Be("newuser@test.com");
+ result.OAuthProviderId.Should().Be(authentikUid);
+ result.InviterId.Should().Be(inviter.Id);
+ result.AvailableInvites.Should().Be(0);
+ result.Disabled.Should().BeFalse();
+ result.Id.Should().NotBeEmpty();
+ }
+
+ #endregion
+
+ #region GetUserByOAuthProviderIdAsync Tests
+
+ [Fact]
+ public async Task GetUserByOAuthProviderIdAsync_WithExistingUser_ReturnsUser()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var oAuthProviderId = "oauth-provider-123";
+ var user = new User
+ {
+ Username = "testuser",
+ Email = "test@test.com",
+ OAuthProviderId = oAuthProviderId,
+ Disabled = false,
+ AvailableInvites = 5
+ };
+ dbContext.Users.Add(user);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.GetUserByOAuthProviderIdAsync(oAuthProviderId);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Id.Should().Be(user.Id);
+ result.Username.Should().Be("testuser");
+ result.OAuthProviderId.Should().Be(oAuthProviderId);
+ }
+
+ [Fact]
+ public async Task GetUserByOAuthProviderIdAsync_WithNonExistingUser_ReturnsNull()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.GetUserByOAuthProviderIdAsync("non-existing-id");
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ #endregion
+
+ #region GetUsers Tests
+
+ [Fact]
+ public async Task GetUsers_ReturnsAllUsers()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var user1 = CreateTestUser("user1", "user1@test.com");
+ var user2 = CreateTestUser("user2", "user2@test.com");
+ var user3 = CreateTestUser("user3", "user3@test.com");
+ dbContext.Users.AddRange(user1, user2, user3);
+ await dbContext.SaveChangesAsync();
+
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.GetUsers().ToListAsync();
+
+ // Assert
+ result.Should().HaveCount(3);
+ result.Select(u => u.Username).Should().BeEquivalentTo(new[] { "user1", "user2", "user3" });
+ }
+
+ [Fact]
+ public async Task GetUsers_WithEmptyDb_ReturnsEmptyQueryable()
+ {
+ // Arrange
+ using var dbContext = CreateDbContext();
+ var authClient = Substitute.For();
+ var service = CreateService(dbContext, authClient);
+
+ // Act
+ var result = await service.GetUsers().ToListAsync();
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ #endregion
+}
diff --git a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj
index 633eb80..ee73c28 100644
--- a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj
+++ b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj
@@ -24,4 +24,9 @@
+
+
+
+
+
diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs
index 0d6528d..83afc5d 100644
--- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs
+++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs
@@ -1,38 +1,53 @@
-using FictionArchive.Service.Shared.Constants;
+using System.Security.Claims;
using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
+using HotChocolate.Types;
namespace FictionArchive.Service.UserService.GraphQL;
public class Mutation
{
- [Authorize(Roles = [AuthorizationConstants.Roles.Admin])]
- public async Task RegisterUser(string username, string email, string oAuthProviderId,
- string? inviterOAuthProviderId, UserManagementService userManagementService)
+ [Authorize]
+ [Error]
+ public async Task InviteUser(
+ string email,
+ string username,
+ UserManagementService userManagementService,
+ ClaimsPrincipal claimsPrincipal)
{
- var user = await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId);
+ // Get the current user's OAuth provider ID from claims
+ var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value;
+ if (string.IsNullOrEmpty(oAuthProviderId))
+ {
+ throw new InvalidOperationException("Unable to determine current user identity");
+ }
+
+ // Get the inviter from the database
+ var inviter = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId);
+ if (inviter == null)
+ {
+ throw new InvalidOperationException("Current user not found in the system");
+ }
+
+ // Invite the new user
+ var newUser = await userManagementService.InviteUserAsync(inviter, email, username);
+ if (newUser == null)
+ {
+ throw new InvalidOperationException(
+ "Failed to invite user. Either you have no available invites, or the email/username is already in use.");
+ }
return new UserDto
{
- Id = user.Id,
- CreatedTime = user.CreatedTime,
- LastUpdatedTime = user.LastUpdatedTime,
- Username = user.Username,
- Email = user.Email,
- Disabled = user.Disabled,
- Inviter = user.Inviter != null
- ? new UserDto
- {
- Id = user.Inviter.Id,
- CreatedTime = user.Inviter.CreatedTime,
- LastUpdatedTime = user.Inviter.LastUpdatedTime,
- Username = user.Inviter.Username,
- Email = user.Inviter.Email,
- Disabled = user.Inviter.Disabled,
- Inviter = null // Limit recursion to one level
- }
- : null
+ Id = newUser.Id,
+ CreatedTime = newUser.CreatedTime,
+ LastUpdatedTime = newUser.LastUpdatedTime,
+ Username = newUser.Username,
+ Email = newUser.Email,
+ Disabled = newUser.Disabled,
+ AvailableInvites = newUser.AvailableInvites,
+ InviterId = newUser.InviterId
};
}
}
diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs
index eac54b3..bf7ba30 100644
--- a/FictionArchive.Service.UserService/GraphQL/Query.cs
+++ b/FictionArchive.Service.UserService/GraphQL/Query.cs
@@ -1,3 +1,4 @@
+using System.Security.Claims;
using FictionArchive.Service.UserService.Models.DTOs;
using FictionArchive.Service.UserService.Services;
using HotChocolate.Authorization;
@@ -7,9 +8,38 @@ namespace FictionArchive.Service.UserService.GraphQL;
public class Query
{
[Authorize]
- public IQueryable GetUsers(UserManagementService userManagementService)
+ public async Task GetAvailableInvites(
+ UserManagementService userManagementService,
+ ClaimsPrincipal claimsPrincipal)
{
- return userManagementService.GetUsers().Select(user => new UserDto
+ var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value;
+ if (string.IsNullOrEmpty(oAuthProviderId))
+ {
+ return 0;
+ }
+
+ var user = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId);
+ return user?.AvailableInvites ?? 0;
+ }
+
+ [Authorize]
+ public async Task GetCurrentUser(
+ UserManagementService userManagementService,
+ ClaimsPrincipal claimsPrincipal)
+ {
+ var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value;
+ if (string.IsNullOrEmpty(oAuthProviderId))
+ {
+ return null;
+ }
+
+ var user = await userManagementService.GetUserByOAuthProviderIdAsync(oAuthProviderId);
+ if (user == null)
+ {
+ return null;
+ }
+
+ return new UserDto
{
Id = user.Id,
CreatedTime = user.CreatedTime,
@@ -17,18 +47,8 @@ public class Query
Username = user.Username,
Email = user.Email,
Disabled = user.Disabled,
- Inviter = user.Inviter != null
- ? new UserDto
- {
- Id = user.Inviter.Id,
- CreatedTime = user.Inviter.CreatedTime,
- LastUpdatedTime = user.Inviter.LastUpdatedTime,
- Username = user.Inviter.Username,
- Email = user.Inviter.Email,
- Disabled = user.Inviter.Disabled,
- Inviter = null // Limit recursion to one level
- }
- : null
- });
+ AvailableInvites = user.AvailableInvites,
+ InviterId = user.InviterId
+ };
}
}
diff --git a/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs
new file mode 100644
index 0000000..7933d4f
--- /dev/null
+++ b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs
@@ -0,0 +1,83 @@
+//
+using System;
+using FictionArchive.Service.UserService.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FictionArchive.Service.UserService.Migrations
+{
+ [DbContext(typeof(UserServiceDbContext))]
+ [Migration("20251229151921_AddAvailableInvites")]
+ partial class AddAvailableInvites
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FictionArchive.Service.UserService.Models.Database.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvailableInvites")
+ .HasColumnType("integer");
+
+ b.Property("CreatedTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Disabled")
+ .HasColumnType("boolean");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("InviterId")
+ .HasColumnType("uuid");
+
+ b.Property("LastUpdatedTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OAuthProviderId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InviterId");
+
+ b.HasIndex("OAuthProviderId")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("FictionArchive.Service.UserService.Models.Database.User", b =>
+ {
+ b.HasOne("FictionArchive.Service.UserService.Models.Database.User", "Inviter")
+ .WithMany()
+ .HasForeignKey("InviterId");
+
+ b.Navigation("Inviter");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs
new file mode 100644
index 0000000..c8331e8
--- /dev/null
+++ b/FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FictionArchive.Service.UserService.Migrations
+{
+ ///
+ public partial class AddAvailableInvites : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "AvailableInvites",
+ table: "Users",
+ type: "integer",
+ nullable: false,
+ defaultValue: 0);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "AvailableInvites",
+ table: "Users");
+ }
+ }
+}
diff --git a/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs b/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs
index ffc0bd4..6b76e15 100644
--- a/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs
+++ b/FictionArchive.Service.UserService/Migrations/UserServiceDbContextModelSnapshot.cs
@@ -29,6 +29,9 @@ namespace FictionArchive.Service.UserService.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
+ b.Property("AvailableInvites")
+ .HasColumnType("integer");
+
b.Property("CreatedTime")
.HasColumnType("timestamp with time zone");
diff --git a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs
index 787b27e..d5948ca 100644
--- a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs
+++ b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs
@@ -7,9 +7,10 @@ public class UserDto
public Guid Id { get; init; }
public Instant CreatedTime { get; init; }
public Instant LastUpdatedTime { get; init; }
+
public required string Username { get; init; }
public required string Email { get; init; }
- // OAuthProviderId intentionally omitted for security
public bool Disabled { get; init; }
- public UserDto? Inviter { get; init; }
+ public int AvailableInvites { get; init; }
+ public Guid? InviterId { get; init; }
}
diff --git a/FictionArchive.Service.UserService/Models/Database/User.cs b/FictionArchive.Service.UserService/Models/Database/User.cs
index aa7b4e3..02a100c 100644
--- a/FictionArchive.Service.UserService/Models/Database/User.cs
+++ b/FictionArchive.Service.UserService/Models/Database/User.cs
@@ -6,15 +6,13 @@ namespace FictionArchive.Service.UserService.Models.Database;
[Index(nameof(OAuthProviderId), IsUnique = true)]
public class User : BaseEntity
{
- public string Username { get; set; }
- public string Email { get; set; }
- public string OAuthProviderId { get; set; }
-
-
+ public required string Username { get; set; }
+ public required string Email { get; set; }
+ public required string OAuthProviderId { get; set; }
public bool Disabled { get; set; }
-
- ///
- /// The user that generated an invite used by this user.
- ///
+ public int AvailableInvites { get; set; } = 0;
+
+ // Navigation properties
+ public Guid? InviterId { get; set; }
public User? Inviter { get; set; }
}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs b/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs
deleted file mode 100644
index 13a832c..0000000
--- a/FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using FictionArchive.Service.Shared.Services.EventBus;
-
-namespace FictionArchive.Service.UserService.Models.IntegrationEvents;
-
-public class AuthUserAddedEvent : IIntegrationEvent
-{
- public string OAuthProviderId { get; set; }
-
- public string InviterOAuthProviderId { get; set; }
-
- // The email of the user that created the event
- public string EventUserEmail { get; set; }
-
- // The username of the user that created the event
- public string EventUserUsername { get; set; }
-}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Program.cs b/FictionArchive.Service.UserService/Program.cs
index 1398ce9..419c199 100644
--- a/FictionArchive.Service.UserService/Program.cs
+++ b/FictionArchive.Service.UserService/Program.cs
@@ -1,11 +1,12 @@
+using System.Net.Http.Headers;
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.UserService.GraphQL;
-using FictionArchive.Service.UserService.Models.IntegrationEvents;
using FictionArchive.Service.UserService.Services;
-using FictionArchive.Service.UserService.Services.EventHandlers;
+using FictionArchive.Service.UserService.Services.AuthenticationClient;
+using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
namespace FictionArchive.Service.UserService;
@@ -25,19 +26,34 @@ public class Program
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
- })
- .Subscribe();
+ });
}
#endregion
-
+
#region GraphQL
-
+
builder.Services.AddDefaultGraphQl()
.AddAuthorization();
-
+
#endregion
-
+
+ #region Authentik Client
+
+ builder.Services.Configure(
+ builder.Configuration.GetSection("Authentik"));
+
+ var authentikConfig = builder.Configuration.GetSection("Authentik").Get();
+ builder.Services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri(authentikConfig?.BaseUrl ?? "https://localhost");
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", authentikConfig?.ApiToken ?? "");
+ })
+ .AddStandardResilienceHandler();
+
+ #endregion
+
builder.Services.RegisterDbContext(
builder.Configuration.GetConnectionString("DefaultConnection"),
skipInfrastructure: isSchemaExport);
diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs
new file mode 100644
index 0000000..5e885e4
--- /dev/null
+++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+
+public class AuthentikAddUserRequest
+{
+ [JsonPropertyName("username")]
+ public required string Username { get; set; }
+
+ [JsonPropertyName("name")]
+ public required string DisplayName { get; set; }
+
+ [JsonPropertyName("email")]
+ public required string Email { get; set; }
+
+ [JsonPropertyName("is_active")]
+ public bool IsActive { get; set; } = true;
+
+ [JsonPropertyName("type")]
+ public string Type { get; } = "external";
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs
new file mode 100644
index 0000000..4f3400b
--- /dev/null
+++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs
@@ -0,0 +1,78 @@
+using System.Net.Http.Json;
+
+namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+
+public class AuthentikClient : IAuthenticationServiceClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+
+ public AuthentikClient(HttpClient httpClient, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+ }
+
+ public async Task CreateUserAsync(string username, string email, string displayName)
+ {
+ var request = new AuthentikAddUserRequest
+ {
+ Username = username,
+ Email = email,
+ DisplayName = displayName,
+ IsActive = true
+ };
+
+ try
+ {
+ var response = await _httpClient.PostAsJsonAsync("/api/v3/core/users/", request);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError(
+ "Failed to create user in Authentik. Status: {StatusCode}, Error: {Error}",
+ response.StatusCode, errorContent);
+ return null;
+ }
+
+ var userResponse = await response.Content.ReadFromJsonAsync();
+ _logger.LogInformation("Successfully created user {Username} in Authentik with pk {Pk}",
+ username, userResponse?.Pk);
+
+ return userResponse;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception while creating user {Username} in Authentik", username);
+ return null;
+ }
+ }
+
+ public async Task SendRecoveryEmailAsync(int authentikUserId)
+ {
+ try
+ {
+ var response = await _httpClient.PostAsync(
+ $"/api/v3/core/users/{authentikUserId}/recovery_email/",
+ null);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ _logger.LogError(
+ "Failed to send recovery email for user {UserId}. Status: {StatusCode}, Error: {Error}",
+ authentikUserId, response.StatusCode, errorContent);
+ return false;
+ }
+
+ _logger.LogInformation("Successfully sent recovery email to Authentik user {UserId}", authentikUserId);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception while sending recovery email to Authentik user {UserId}", authentikUserId);
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs
new file mode 100644
index 0000000..00d5afc
--- /dev/null
+++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs
@@ -0,0 +1,7 @@
+namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+
+public class AuthentikConfiguration
+{
+ public string BaseUrl { get; set; } = string.Empty;
+ public string ApiToken { get; set; } = string.Empty;
+}
diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs
new file mode 100644
index 0000000..fb945be
--- /dev/null
+++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+
+public class AuthentikUserResponse
+{
+ [JsonPropertyName("pk")]
+ public int Pk { get; set; }
+
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("email")]
+ public string Email { get; set; } = string.Empty;
+
+ [JsonPropertyName("is_active")]
+ public bool IsActive { get; set; }
+
+ [JsonPropertyName("is_superuser")]
+ public bool IsSuperuser { get; set; }
+
+ [JsonPropertyName("uid")]
+ public string Uid { get; set; } = string.Empty;
+}
diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs
new file mode 100644
index 0000000..4e5b9a5
--- /dev/null
+++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs
@@ -0,0 +1,22 @@
+using FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik;
+
+namespace FictionArchive.Service.UserService.Services.AuthenticationClient;
+
+public interface IAuthenticationServiceClient
+{
+ ///
+ /// Creates a new user in the authentication provider.
+ ///
+ /// The username for the new user
+ /// The email address for the new user
+ /// The display name for the new user
+ /// The created user response, or null if creation failed
+ Task CreateUserAsync(string username, string email, string displayName);
+
+ ///
+ /// Sends a password recovery email to the user.
+ ///
+ /// The Authentik user ID (pk)
+ /// True if the email was sent successfully, false otherwise
+ Task SendRecoveryEmailAsync(int authentikUserId);
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs b/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs
deleted file mode 100644
index 120380b..0000000
--- a/FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using FictionArchive.Service.Shared.Services.EventBus;
-using FictionArchive.Service.UserService.Models.IntegrationEvents;
-using FictionArchive.Service.UserService.Models.Database;
-using Microsoft.EntityFrameworkCore; // Add this line to include the UserModel
-
-namespace FictionArchive.Service.UserService.Services.EventHandlers;
-
-public class AuthUserAddedEventHandler : IIntegrationEventHandler
-{
- private readonly UserManagementService _userManagementService;
- private readonly ILogger _logger;
-
- public AuthUserAddedEventHandler(UserServiceDbContext dbContext, ILogger logger, UserManagementService userManagementService)
- {
- _logger = logger;
- _userManagementService = userManagementService;
- }
-
- public async Task Handle(AuthUserAddedEvent @event)
- {
- await _userManagementService.RegisterUser(@event.EventUserUsername, @event.EventUserEmail, @event.OAuthProviderId, @event.InviterOAuthProviderId);
- }
-}
\ No newline at end of file
diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs
index b1de631..cdddcb7 100644
--- a/FictionArchive.Service.UserService/Services/UserManagementService.cs
+++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs
@@ -1,4 +1,5 @@
using FictionArchive.Service.UserService.Models.Database;
+using FictionArchive.Service.UserService.Services.AuthenticationClient;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserService.Services;
@@ -7,37 +8,112 @@ public class UserManagementService
{
private readonly ILogger _logger;
private readonly UserServiceDbContext _dbContext;
+ private readonly IAuthenticationServiceClient _authClient;
- public UserManagementService(UserServiceDbContext dbContext, ILogger logger)
+ public UserManagementService(
+ UserServiceDbContext dbContext,
+ ILogger logger,
+ IAuthenticationServiceClient authClient)
{
_dbContext = dbContext;
_logger = logger;
+ _authClient = authClient;
}
- public async Task RegisterUser(string username, string email, string oAuthProviderId,
- string? inviterOAuthProviderId)
+ ///
+ /// Invites a new user by creating them in Authentik, saving to the database, and sending a recovery email.
+ ///
+ /// The user sending the invite
+ /// Email address of the invitee
+ /// Username for the invitee
+ /// The created user, or null if the invite failed
+ public async Task InviteUserAsync(User inviter, string email, string username)
{
- var newUser = new User();
- User? inviter =
- await _dbContext.Users.FirstOrDefaultAsync(user => user.OAuthProviderId == inviterOAuthProviderId);
- if (inviter == null && inviterOAuthProviderId != null)
+ // Check if inviter has available invites
+ if (inviter.AvailableInvites <= 0)
{
- _logger.LogCritical(
- "A user with OAuthProviderId {OAuthProviderId} was marked as having inviter with OAuthProviderId {inviterOAuthProviderId}, but no user was found with that value.",
- inviterOAuthProviderId, inviterOAuthProviderId);
- newUser.Disabled = true;
+ _logger.LogWarning("User {InviterId} has no available invites", inviter.Id);
+ return null;
}
- newUser.Username = username;
- newUser.Email = email;
- newUser.OAuthProviderId = oAuthProviderId;
+ // Check if email is already in use
+ var existingUser = await _dbContext.Users
+ .AsQueryable()
+ .FirstOrDefaultAsync(u => u.Email == email);
+
+ if (existingUser != null)
+ {
+ _logger.LogWarning("Email {Email} is already in use", email);
+ return null;
+ }
+
+ // Check if username is already in use
+ var existingUsername = await _dbContext.Users
+ .AsQueryable()
+ .FirstOrDefaultAsync(u => u.Username == username);
+
+ if (existingUsername != null)
+ {
+ _logger.LogWarning("Username {Username} is already in use", username);
+ return null;
+ }
+
+ // Create user in Authentik
+ var authentikUser = await _authClient.CreateUserAsync(username, email, username);
+ if (authentikUser == null)
+ {
+ _logger.LogError("Failed to create user {Username} in Authentik", username);
+ return null;
+ }
+
+ // Send recovery email via Authentik
+ var emailSent = await _authClient.SendRecoveryEmailAsync(authentikUser.Pk);
+ if (!emailSent)
+ {
+ _logger.LogWarning(
+ "User {Username} was created in Authentik but recovery email failed to send. Authentik pk: {Pk}",
+ username, authentikUser.Pk);
+ // Continue anyway - the user is created, admin can resend email manually
+ }
+
+ // Create user in local database
+ var newUser = new User
+ {
+ Username = username,
+ Email = email,
+ OAuthProviderId = authentikUser.Uid,
+ Disabled = false,
+ AvailableInvites = 0,
+ InviterId = inviter.Id
+ };
+
+ _dbContext.Users.Add(newUser);
+
+ // Decrement inviter's available invites
+ inviter.AvailableInvites--;
+
+ await _dbContext.SaveChangesAsync();
+
+ _logger.LogInformation(
+ "User {Username} was successfully invited by {InviterId}. New user id: {NewUserId}",
+ username, inviter.Id, newUser.Id);
- _dbContext.Users.Add(newUser); // Add the new user to the DbContext
- await _dbContext.SaveChangesAsync(); // Save changes to the database
-
return newUser;
}
+ ///
+ /// Gets a user by their OAuth provider ID (Authentik UID).
+ ///
+ public async Task GetUserByOAuthProviderIdAsync(string oAuthProviderId)
+ {
+ return await _dbContext.Users
+ .AsQueryable()
+ .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
+ }
+
+ ///
+ /// Gets all users as a queryable for GraphQL.
+ ///
public IQueryable GetUsers()
{
return _dbContext.Users.AsQueryable();
diff --git a/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs b/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs
index d743f68..a053a3e 100644
--- a/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs
+++ b/FictionArchive.Service.UserService/Services/UserServiceDbContext.cs
@@ -7,7 +7,7 @@ namespace FictionArchive.Service.UserService.Services;
public class UserServiceDbContext : FictionArchiveDbContext
{
public DbSet Users { get; set; }
-
+
public UserServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger)
{
}
diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json
index 11f3af1..3eeb9d8 100644
--- a/FictionArchive.Service.UserService/appsettings.json
+++ b/FictionArchive.Service.UserService/appsettings.json
@@ -12,6 +12,10 @@
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "UserService"
},
+ "Authentik": {
+ "BaseUrl": "https://auth.orfl.xyz",
+ "ApiToken": "REPLACE_ME"
+ },
"AllowedHosts": "*",
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
diff --git a/FictionArchive.sln b/FictionArchive.sln
index 81af8d3..6a0e6ef 100644
--- a/FictionArchive.sln
+++ b/FictionArchive.sln
@@ -1,5 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
+#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Common", "FictionArchive.Common\FictionArchive.Common.csproj", "{ABF1BA10-9E76-45BE-9947-E20445A68147}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.API", "FictionArchive.API\FictionArchive.API.csproj", "{420CC1A1-9DBC-40EC-B9E3-D4B25D71B9A9}"
@@ -14,12 +15,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Sche
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService", "FictionArchive.Service.UserService\FictionArchive.Service.UserService.csproj", "{EE4D4795-2F79-4614-886D-AF8DA77120AC}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.AuthenticationService", "FictionArchive.Service.AuthenticationService\FictionArchive.Service.AuthenticationService.csproj", "{70C4AE82-B01E-421D-B590-C0F47E63CD0C}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.FileService", "FictionArchive.Service.FileService\FictionArchive.Service.FileService.csproj", "{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.NovelService.Tests", "FictionArchive.Service.NovelService.Tests\FictionArchive.Service.NovelService.Tests.csproj", "{166E645E-9DFB-44E8-8CC8-FA249A11679F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.UserService.Tests", "FictionArchive.Service.UserService.Tests\FictionArchive.Service.UserService.Tests.csproj", "{10C38C89-983D-4544-8911-F03099F66AB8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -54,10 +55,6 @@ Global
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EE4D4795-2F79-4614-886D-AF8DA77120AC}.Release|Any CPU.Build.0 = Release|Any CPU
- {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {70C4AE82-B01E-421D-B590-C0F47E63CD0C}.Release|Any CPU.Build.0 = Release|Any CPU
{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC64A336-F8A0-4BED-9CA3-1B05AD00631D}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -66,5 +63,9 @@ Global
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{166E645E-9DFB-44E8-8CC8-FA249A11679F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {10C38C89-983D-4544-8911-F03099F66AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {10C38C89-983D-4544-8911-F03099F66AB8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal