From c0290cc5af05dede0f84d2c830da8da8fa561d5e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 11:20:23 -0500 Subject: [PATCH 1/4] [FA-55] User Service backend initial setup --- ...onArchive.Service.UserService.Tests.csproj | 30 ++ .../UserManagementServiceTests.cs | 326 ++++++++++++++++++ .../FictionArchive.Service.UserService.csproj | 5 + .../GraphQL/Mutation.cs | 61 ++-- .../GraphQL/Query.cs | 50 ++- ...1229151921_AddAvailableInvites.Designer.cs | 83 +++++ .../20251229151921_AddAvailableInvites.cs | 29 ++ .../UserServiceDbContextModelSnapshot.cs | 3 + .../Models/DTOs/UserDto.cs | 5 +- .../Models/Database/User.cs | 16 +- .../IntegrationEvents/AuthUserAddedEvent.cs | 16 - FictionArchive.Service.UserService/Program.cs | 32 +- .../Authentik/AuthentikAddUserRequest.cs | 21 ++ .../Authentik/AuthentikClient.cs | 78 +++++ .../Authentik/AuthentikConfiguration.cs | 7 + .../Authentik/AuthentikUserResponse.cs | 27 ++ .../IAuthenticationServiceClient.cs | 22 ++ .../AuthUserAddedEventHandler.cs | 23 -- .../Services/UserManagementService.cs | 110 +++++- .../Services/UserServiceDbContext.cs | 2 +- .../appsettings.json | 4 + FictionArchive.sln | 13 +- 22 files changed, 843 insertions(+), 120 deletions(-) create mode 100644 FictionArchive.Service.UserService.Tests/FictionArchive.Service.UserService.Tests.csproj create mode 100644 FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs create mode 100644 FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.Designer.cs create mode 100644 FictionArchive.Service.UserService/Migrations/20251229151921_AddAvailableInvites.cs delete mode 100644 FictionArchive.Service.UserService/Models/IntegrationEvents/AuthUserAddedEvent.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs create mode 100644 FictionArchive.Service.UserService/Services/AuthenticationClient/IAuthenticationServiceClient.cs delete mode 100644 FictionArchive.Service.UserService/Services/EventHandlers/AuthUserAddedEventHandler.cs 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 From 01d3b9405023ab2e42133d8deea04d14d10bd19c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 14:09:41 -0500 Subject: [PATCH 2/4] [FA-55] Finished aside from deactivation/integration events --- .../Extensions/AuthenticationExtensions.cs | 3 +- .../FictionArchive.Service.UserService.csproj | 1 + .../GraphQL/Mutation.cs | 2 +- .../GraphQL/Query.cs | 61 +++-- .../Models/DTOs/InvitedUserDto.cs | 7 + .../Models/DTOs/UserDto.cs | 1 + .../Models/Database/User.cs | 1 + .../Authentik/AuthentikAddUserRequest.cs | 12 +- .../Authentik/AuthentikClient.cs | 20 +- .../Authentik/AuthentikConfiguration.cs | 1 + .../Authentik/AuthentikUserResponse.cs | 16 +- .../Services/UserManagementService.cs | 14 ++ .../appsettings.json | 3 +- .../components/AuthenticationDisplay.svelte | 6 + .../src/lib/components/SettingsPage.svelte | 211 ++++++++++++++++++ .../src/lib/graphql/__generated__/graphql.ts | 60 +++-- .../lib/graphql/mutations/inviteUser.graphql | 14 ++ .../src/lib/graphql/queries/settings.graphql | 11 + .../src/pages/settings/index.astro | 8 + 19 files changed, 377 insertions(+), 75 deletions(-) create mode 100644 FictionArchive.Service.UserService/Models/DTOs/InvitedUserDto.cs create mode 100644 fictionarchive-web-astro/src/lib/components/SettingsPage.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/inviteUser.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/settings.graphql create mode 100644 fictionarchive-web-astro/src/pages/settings/index.astro diff --git a/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs index 6f1c3e4..ad3707b 100644 --- a/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs +++ b/FictionArchive.Service.Shared/Extensions/AuthenticationExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens; using FictionArchive.Service.Shared.Constants; using FictionArchive.Service.Shared.Models.Authentication; using System.Linq; +using System.Security.Claims; namespace FictionArchive.Service.Shared.Extensions; @@ -78,7 +79,7 @@ public static class AuthenticationExtensions logger.LogDebug( "JWT token validated for subject: {Subject}", - context.Principal?.FindFirst("sub")?.Value ?? "unknown"); + context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown"); return existingEvents?.OnTokenValidated?.Invoke(context) ?? Task.CompletedTask; } diff --git a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj index ee73c28..56c63f6 100644 --- a/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj +++ b/FictionArchive.Service.UserService/FictionArchive.Service.UserService.csproj @@ -22,6 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs index 83afc5d..b994671 100644 --- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs @@ -17,7 +17,7 @@ public class Mutation ClaimsPrincipal claimsPrincipal) { // Get the current user's OAuth provider ID from claims - var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value; + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { throw new InvalidOperationException("Unable to determine current user identity"); diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs index bf7ba30..d2f0254 100644 --- a/FictionArchive.Service.UserService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserService/GraphQL/Query.cs @@ -2,53 +2,42 @@ using System.Security.Claims; using FictionArchive.Service.UserService.Models.DTOs; using FictionArchive.Service.UserService.Services; using HotChocolate.Authorization; +using HotChocolate.Data; namespace FictionArchive.Service.UserService.GraphQL; public class Query { [Authorize] - public async Task GetAvailableInvites( - UserManagementService userManagementService, + [UseProjection] + [UseFirstOrDefault] + public IQueryable GetCurrentUser( + UserServiceDbContext dbContext, ClaimsPrincipal claimsPrincipal) { - var oAuthProviderId = claimsPrincipal.FindFirst("sub")?.Value; + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(oAuthProviderId)) { - return 0; + return Enumerable.Empty().AsQueryable(); } - 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, - LastUpdatedTime = user.LastUpdatedTime, - Username = user.Username, - Email = user.Email, - Disabled = user.Disabled, - AvailableInvites = user.AvailableInvites, - InviterId = user.InviterId - }; + return dbContext.Users + .Where(u => u.OAuthProviderId == oAuthProviderId) + .Select(u => new UserDto + { + Id = u.Id, + CreatedTime = u.CreatedTime, + LastUpdatedTime = u.LastUpdatedTime, + Username = u.Username, + Email = u.Email, + Disabled = u.Disabled, + AvailableInvites = u.AvailableInvites, + InviterId = u.InviterId, + InvitedUsers = u.InvitedUsers.Select(iu => new InvitedUserDto + { + Username = iu.Username, + Email = iu.Email + }).ToList() + }); } } diff --git a/FictionArchive.Service.UserService/Models/DTOs/InvitedUserDto.cs b/FictionArchive.Service.UserService/Models/DTOs/InvitedUserDto.cs new file mode 100644 index 0000000..6a50ed3 --- /dev/null +++ b/FictionArchive.Service.UserService/Models/DTOs/InvitedUserDto.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.UserService.Models.DTOs; + +public class InvitedUserDto +{ + public required string Username { get; init; } + public required string Email { get; init; } +} diff --git a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs index d5948ca..3a11c80 100644 --- a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs +++ b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs @@ -13,4 +13,5 @@ public class UserDto public bool Disabled { get; init; } public int AvailableInvites { get; init; } public Guid? InviterId { get; init; } + public List? InvitedUsers { get; init; } } diff --git a/FictionArchive.Service.UserService/Models/Database/User.cs b/FictionArchive.Service.UserService/Models/Database/User.cs index 02a100c..417fa2e 100644 --- a/FictionArchive.Service.UserService/Models/Database/User.cs +++ b/FictionArchive.Service.UserService/Models/Database/User.cs @@ -15,4 +15,5 @@ public class User : BaseEntity // Navigation properties public Guid? InviterId { get; set; } public User? Inviter { get; set; } + public ICollection InvitedUsers { get; set; } = new List(); } \ No newline at end of file diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs index 5e885e4..6802595 100644 --- a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikAddUserRequest.cs @@ -1,21 +1,21 @@ -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; public class AuthentikAddUserRequest { - [JsonPropertyName("username")] + [JsonProperty("username")] public required string Username { get; set; } - [JsonPropertyName("name")] + [JsonProperty("name")] public required string DisplayName { get; set; } - [JsonPropertyName("email")] + [JsonProperty("email")] public required string Email { get; set; } - [JsonPropertyName("is_active")] + [JsonProperty("is_active")] public bool IsActive { get; set; } = true; - [JsonPropertyName("type")] + [JsonProperty("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 index 4f3400b..c536e55 100644 --- a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikClient.cs @@ -1,4 +1,6 @@ -using System.Net.Http.Json; +using System.Text; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; @@ -6,11 +8,16 @@ public class AuthentikClient : IAuthenticationServiceClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; + private readonly AuthentikConfiguration _configuration; - public AuthentikClient(HttpClient httpClient, ILogger logger) + public AuthentikClient( + HttpClient httpClient, + ILogger logger, + IOptions configuration) { _httpClient = httpClient; _logger = logger; + _configuration = configuration.Value; } public async Task CreateUserAsync(string username, string email, string displayName) @@ -25,7 +32,9 @@ public class AuthentikClient : IAuthenticationServiceClient try { - var response = await _httpClient.PostAsJsonAsync("/api/v3/core/users/", request); + var json = JsonConvert.SerializeObject(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("/api/v3/core/users/", content); if (!response.IsSuccessStatusCode) { @@ -36,7 +45,8 @@ public class AuthentikClient : IAuthenticationServiceClient return null; } - var userResponse = await response.Content.ReadFromJsonAsync(); + var responseJson = await response.Content.ReadAsStringAsync(); + var userResponse = JsonConvert.DeserializeObject(responseJson); _logger.LogInformation("Successfully created user {Username} in Authentik with pk {Pk}", username, userResponse?.Pk); @@ -54,7 +64,7 @@ public class AuthentikClient : IAuthenticationServiceClient try { var response = await _httpClient.PostAsync( - $"/api/v3/core/users/{authentikUserId}/recovery_email/", + $"/api/v3/core/users/{authentikUserId}/recovery_email/?email_stage={_configuration.EmailStageId}", null); if (!response.IsSuccessStatusCode) diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs index 00d5afc..aeb815c 100644 --- a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikConfiguration.cs @@ -4,4 +4,5 @@ public class AuthentikConfiguration { public string BaseUrl { get; set; } = string.Empty; public string ApiToken { get; set; } = string.Empty; + public string EmailStageId { get; set; } } diff --git a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs index fb945be..6f2938e 100644 --- a/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs +++ b/FictionArchive.Service.UserService/Services/AuthenticationClient/Authentik/AuthentikUserResponse.cs @@ -1,27 +1,27 @@ -using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace FictionArchive.Service.UserService.Services.AuthenticationClient.Authentik; public class AuthentikUserResponse { - [JsonPropertyName("pk")] + [JsonProperty("pk")] public int Pk { get; set; } - [JsonPropertyName("username")] + [JsonProperty("username")] public string Username { get; set; } = string.Empty; - [JsonPropertyName("name")] + [JsonProperty("name")] public string Name { get; set; } = string.Empty; - [JsonPropertyName("email")] + [JsonProperty("email")] public string Email { get; set; } = string.Empty; - [JsonPropertyName("is_active")] + [JsonProperty("is_active")] public bool IsActive { get; set; } - [JsonPropertyName("is_superuser")] + [JsonProperty("is_superuser")] public bool IsSuperuser { get; set; } - [JsonPropertyName("uid")] + [JsonProperty("uid")] public string Uid { get; set; } = string.Empty; } diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs index cdddcb7..a6b409a 100644 --- a/FictionArchive.Service.UserService/Services/UserManagementService.cs +++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs @@ -118,4 +118,18 @@ public class UserManagementService { return _dbContext.Users.AsQueryable(); } + + /// + /// Gets all users invited by a specific user. + /// + /// The ID of the user who sent the invites + /// List of users invited by the specified user + public async Task> GetInvitedByUserAsync(Guid inviterId) + { + return await _dbContext.Users + .AsQueryable() + .Where(u => u.InviterId == inviterId) + .OrderByDescending(u => u.CreatedTime) + .ToListAsync(); + } } \ No newline at end of file diff --git a/FictionArchive.Service.UserService/appsettings.json b/FictionArchive.Service.UserService/appsettings.json index 3eeb9d8..2182f2f 100644 --- a/FictionArchive.Service.UserService/appsettings.json +++ b/FictionArchive.Service.UserService/appsettings.json @@ -14,7 +14,8 @@ }, "Authentik": { "BaseUrl": "https://auth.orfl.xyz", - "ApiToken": "REPLACE_ME" + "ApiToken": "REPLACE_ME", + "EmailStageId": "10df0c18-8802-4ec7-852e-3cdd355514d3" }, "AllowedHosts": "*", "OIDC": { diff --git a/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte b/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte index c65d882..a58be93 100644 --- a/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte +++ b/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte @@ -44,6 +44,12 @@
+ + Settings + diff --git a/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte b/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte new file mode 100644 index 0000000..dc1a998 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte @@ -0,0 +1,211 @@ + + +
+
+

Settings

+

Manage your account settings

+
+ + {#if fetching} +
+
Loading...
+
+ {:else if error} + + +

{error}

+ +
+
+ {:else} + + + Invites + + + + + + + + Invite a User + 0 ? 'default' : 'secondary'}> + {availableInvites} remaining + + + + +
+
+
+ + +
+
+ + +
+
+ + {#if formError} +

{formError}

+ {/if} + + {#if formSuccess} +

+ Invitation sent successfully! The user will receive an email to set up their account. +

+ {/if} + + +
+
+
+ + + + + Invited Users + + + {#if !currentUser?.invitedUsers?.length} +

+ You haven't invited anyone yet. +

+ {:else} +
+ {#each currentUser.invitedUsers as user} +
+
+

{user.username}

+

{user.email}

+
+
+ {/each} +
+ {/if} +
+
+
+
+ {/if} +
diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 7ad2125..5f7f3b2 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -156,6 +156,27 @@ export type InstantFilterInput = { or?: InputMaybe>; }; +export type InvalidOperationError = Error & { + message: Scalars['String']['output']; +}; + +export type InviteUserError = InvalidOperationError; + +export type InviteUserInput = { + email: Scalars['String']['input']; + username: Scalars['String']['input']; +}; + +export type InviteUserPayload = { + errors: Maybe>; + userDto: Maybe; +}; + +export type InvitedUserDto = { + email: Scalars['String']['output']; + username: Scalars['String']['output']; +}; + export type JobKey = { group: Scalars['String']['output']; name: Scalars['String']['output']; @@ -215,7 +236,7 @@ export type Mutation = { deleteNovel: DeleteNovelPayload; fetchChapterContents: FetchChapterContentsPayload; importNovel: ImportNovelPayload; - registerUser: RegisterUserPayload; + inviteUser: InviteUserPayload; runJob: RunJobPayload; scheduleEventJob: ScheduleEventJobPayload; translateText: TranslateTextPayload; @@ -242,8 +263,8 @@ export type MutationImportNovelArgs = { }; -export type MutationRegisterUserArgs = { - input: RegisterUserInput; +export type MutationInviteUserArgs = { + input: InviteUserInput; }; @@ -422,11 +443,11 @@ export type PersonDtoSortInput = { export type Query = { chapter: Maybe; + currentUser: Array; jobs: Array; novels: Maybe; translationEngines: Array; translationRequests: Maybe; - users: Array; }; @@ -463,17 +484,6 @@ export type QueryTranslationRequestsArgs = { where?: InputMaybe; }; -export type RegisterUserInput = { - email: Scalars['String']['input']; - inviterOAuthProviderId?: InputMaybe; - oAuthProviderId: Scalars['String']['input']; - username: Scalars['String']['input']; -}; - -export type RegisterUserPayload = { - userDto: Maybe; -}; - export type RunJobError = JobPersistenceError; export type RunJobInput = { @@ -700,11 +710,13 @@ export type UnsignedIntOperationFilterInputType = { }; export type UserDto = { + availableInvites: Scalars['Int']['output']; createdTime: Scalars['Instant']['output']; disabled: Scalars['Boolean']['output']; email: Scalars['String']['output']; id: Scalars['UUID']['output']; - inviter: Maybe; + invitedUsers: Maybe>; + inviterId: Maybe; lastUpdatedTime: Scalars['Instant']['output']; username: Scalars['String']['output']; }; @@ -738,6 +750,13 @@ export type ImportNovelMutationVariables = Exact<{ export type ImportNovelMutation = { importNovel: { novelUpdateRequestedEvent: { novelUrl: string } | null } }; +export type InviteUserMutationVariables = Exact<{ + input: InviteUserInput; +}>; + + +export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: string, email: string } | null, errors: Array<{ message: string }> | null } }; + export type GetChapterQueryVariables = Exact<{ novelId: Scalars['UnsignedInt']['input']; chapterOrder: Scalars['UnsignedInt']['input']; @@ -763,9 +782,16 @@ export type NovelsQueryVariables = Exact<{ export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, chapters: Array<{ order: any, name: string }>, tags: Array<{ key: string, displayName: string, tagType: TagType }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null }; +export type GetSettingsPageDataQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetSettingsPageDataQuery = { currentUser: Array<{ id: any, username: string, availableInvites: number, invitedUsers: Array<{ username: string, email: string }> | null }> }; + export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"totalChapters"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode; export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetSettingsPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSettingsPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"availableInvites"}},{"kind":"Field","name":{"kind":"Name","value":"invitedUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/inviteUser.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/inviteUser.graphql new file mode 100644 index 0000000..1a0fb57 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/inviteUser.graphql @@ -0,0 +1,14 @@ +mutation InviteUser($input: InviteUserInput!) { + inviteUser(input: $input) { + userDto { + id + username + email + } + errors { + ... on InvalidOperationError { + message + } + } + } +} diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/settings.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/settings.graphql new file mode 100644 index 0000000..5dbb1c3 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/queries/settings.graphql @@ -0,0 +1,11 @@ +query GetSettingsPageData { + currentUser { + id + username + availableInvites + invitedUsers { + username + email + } + } +} diff --git a/fictionarchive-web-astro/src/pages/settings/index.astro b/fictionarchive-web-astro/src/pages/settings/index.astro new file mode 100644 index 0000000..c8a4fc5 --- /dev/null +++ b/fictionarchive-web-astro/src/pages/settings/index.astro @@ -0,0 +1,8 @@ +--- +import AppLayout from '../../layouts/AppLayout.astro'; +import SettingsPage from '../../lib/components/SettingsPage.svelte'; +--- + + + + From ebb2e6e7fc44383cdc4855b2a5ce5ea7b61f628f Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 14:33:08 -0500 Subject: [PATCH 3/4] [FA-55] User service should be done --- .../UserManagementServiceTests.cs | 7 +++++-- .../IntegrationEvents/UserInvitedEvent.cs | 17 +++++++++++++++++ .../Services/UserManagementService.cs | 18 +++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs diff --git a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs index e9f8dfd..c7c804d 100644 --- a/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs +++ b/FictionArchive.Service.UserService.Tests/UserManagementServiceTests.cs @@ -1,3 +1,4 @@ +using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.UserService.Models.Database; using FictionArchive.Service.UserService.Services; using FictionArchive.Service.UserService.Services.AuthenticationClient; @@ -25,12 +26,14 @@ public class UserManagementServiceTests private static UserManagementService CreateService( UserServiceDbContext dbContext, - IAuthenticationServiceClient authClient) + IAuthenticationServiceClient authClient, + IEventBus? eventBus = null) { return new UserManagementService( dbContext, NullLogger.Instance, - authClient); + authClient, + eventBus ?? Substitute.For()); } private static User CreateTestUser(string username, string email, int availableInvites = 5) diff --git a/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs b/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs new file mode 100644 index 0000000..5039e75 --- /dev/null +++ b/FictionArchive.Service.UserService/Models/IntegrationEvents/UserInvitedEvent.cs @@ -0,0 +1,17 @@ +using FictionArchive.Service.Shared.Services.EventBus; + +namespace FictionArchive.Service.UserService.Models.IntegrationEvents; + +public class UserInvitedEvent : IIntegrationEvent +{ + // Invited user info + public Guid InvitedUserId { get; set; } + public required string InvitedUsername { get; set; } + public required string InvitedEmail { get; set; } + public required string InvitedOAuthProviderId { get; set; } + + // Inviter info + public Guid InviterId { get; set; } + public required string InviterUsername { get; set; } + public required string InviterOAuthProviderId { get; set; } +} diff --git a/FictionArchive.Service.UserService/Services/UserManagementService.cs b/FictionArchive.Service.UserService/Services/UserManagementService.cs index a6b409a..9e7a6cb 100644 --- a/FictionArchive.Service.UserService/Services/UserManagementService.cs +++ b/FictionArchive.Service.UserService/Services/UserManagementService.cs @@ -1,4 +1,6 @@ +using FictionArchive.Service.Shared.Services.EventBus; using FictionArchive.Service.UserService.Models.Database; +using FictionArchive.Service.UserService.Models.IntegrationEvents; using FictionArchive.Service.UserService.Services.AuthenticationClient; using Microsoft.EntityFrameworkCore; @@ -9,15 +11,18 @@ public class UserManagementService private readonly ILogger _logger; private readonly UserServiceDbContext _dbContext; private readonly IAuthenticationServiceClient _authClient; + private readonly IEventBus _eventBus; public UserManagementService( UserServiceDbContext dbContext, ILogger logger, - IAuthenticationServiceClient authClient) + IAuthenticationServiceClient authClient, + IEventBus eventBus) { _dbContext = dbContext; _logger = logger; _authClient = authClient; + _eventBus = eventBus; } /// @@ -94,6 +99,17 @@ public class UserManagementService await _dbContext.SaveChangesAsync(); + await _eventBus.Publish(new UserInvitedEvent + { + InvitedUserId = newUser.Id, + InvitedUsername = newUser.Username, + InvitedEmail = newUser.Email, + InvitedOAuthProviderId = newUser.OAuthProviderId, + InviterId = inviter.Id, + InviterUsername = inviter.Username, + InviterOAuthProviderId = inviter.OAuthProviderId + }); + _logger.LogInformation( "User {Username} was successfully invited by {InviterId}. New user id: {NewUserId}", username, inviter.Id, newUser.Id); From 3612c89b99633fab43e8037302ad35584ff0774e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 29 Dec 2025 14:35:17 -0500 Subject: [PATCH 4/4] [FA-55] Resolve linter error --- fictionarchive-web-astro/src/lib/components/SettingsPage.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte b/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte index dc1a998..0ae3d74 100644 --- a/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/SettingsPage.svelte @@ -193,7 +193,7 @@

{:else}
- {#each currentUser.invitedUsers as user} + {#each currentUser.invitedUsers as user (user.username)}

{user.username}