diff --git a/backend/src/Loopless.Api/Endpoints/AdminEndpoints.cs b/backend/src/Loopless.Api/Endpoints/AdminEndpoints.cs index 4effc5e..5e27a5a 100644 --- a/backend/src/Loopless.Api/Endpoints/AdminEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/AdminEndpoints.cs @@ -71,7 +71,7 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder ISender sender, CancellationToken ct) => { - var dto = await sender.Send(new SuspendUserCommand(id, body.Suspend), ct); + var dto = await sender.Send(new SuspendUserCommand(id, body.Suspend, body.Until), ct); return Results.Ok(dto); }) .WithName("SuspendAdminUser") @@ -304,7 +304,7 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder return app; } - private sealed record SuspendUserBody(bool Suspend); + private sealed record SuspendUserBody(bool Suspend, DateTimeOffset? Until = null); private sealed record ToggleFlagBody(bool Enabled); private sealed record ChangeRoleBody(UserRole NewRole); private sealed record BroadcastBody(string Title, string Body, UserRole? Role, string? ActionUrl); diff --git a/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs b/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs index a52ddd9..820b6b4 100644 --- a/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs @@ -1,4 +1,5 @@ using Loopless.Application.DTOs; +using Loopless.Application.Features.Auth.ForceChangePassword; using Loopless.Application.Features.Auth.GetCurrentUser; using Loopless.Application.Features.Auth.GitHubSso; using Loopless.Application.Features.Auth.Login; @@ -51,6 +52,26 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder .ProducesValidationProblem() ; + // Anonymous: completes an admin-forced password reset from the login page. + // The handler verifies the current password against Keycloak first. + group.MapPost("/auth/change-password", async ( + [FromBody] ForceChangePasswordCommand command, + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(command, ct); + return Results.Ok(result); + }) + .AllowAnonymous() + .RequireRateLimiting("auth") + .WithName("ForceChangePassword") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .ProducesValidationProblem() + ; + group.MapPost("/auth/github-sso", async ( [FromBody] GitHubSsoCommand command, ISender sender, @@ -130,12 +151,15 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder .ProducesValidationProblem() ; - group.MapGet("/users/{id:guid}", async ( - Guid id, + // Accepts a GUID (legacy links) or a username — literal routes like + // /users/me and /users/search still win over this parameterized one, + // and those words are reserved by UsernameGenerator. + group.MapGet("/users/{idOrUsername}", async ( + string idOrUsername, ISender sender, CancellationToken ct) => { - var result = await sender.Send(new GetUserByIdQuery(id), ct); + var result = await sender.Send(new GetUserByIdQuery(idOrUsername), ct); return Results.Ok(result); }) .RequireAuthorization() diff --git a/backend/src/Loopless.Api/Middleware/DomainExceptionHandler.cs b/backend/src/Loopless.Api/Middleware/DomainExceptionHandler.cs index acb4c08..75eecc1 100644 --- a/backend/src/Loopless.Api/Middleware/DomainExceptionHandler.cs +++ b/backend/src/Loopless.Api/Middleware/DomainExceptionHandler.cs @@ -10,10 +10,22 @@ public async ValueTask TryHandleAsync( Exception exception, CancellationToken cancellationToken) { + // PasswordChangeRequired carries a machine-readable code so the login page + // can distinguish it from ordinary conflicts and switch to its + // change-password form. + if (exception is PasswordChangeRequiredException pcr) + { + httpContext.Response.StatusCode = StatusCodes.Status409Conflict; + await httpContext.Response.WriteAsJsonAsync( + new { error = pcr.Message, code = "password_change_required" }, cancellationToken); + return true; + } + var (status, message) = exception switch { NotFoundException e => (StatusCodes.Status404NotFound, e.Message), UnauthorizedException e => (StatusCodes.Status401Unauthorized, e.Message), + AccountSetupIncompleteException e => (StatusCodes.Status401Unauthorized, e.Message), ConflictException e => (StatusCodes.Status409Conflict, e.Message), ForbiddenException e => (StatusCodes.Status403Forbidden, e.Message), BadRequestException e => (StatusCodes.Status400BadRequest, e.Message), diff --git a/backend/src/Loopless.Api/Program.cs b/backend/src/Loopless.Api/Program.cs index 13c5867..bd18c2c 100644 --- a/backend/src/Loopless.Api/Program.cs +++ b/backend/src/Loopless.Api/Program.cs @@ -367,6 +367,11 @@ await context.HttpContext.Response.WriteAsync( OrphanCleanupJob.RecurringJobId, job => job.RunAsync(CancellationToken.None), Cron.Daily); + + recurringJobs.AddOrUpdate( + SuspensionExpiryJob.RecurringJobId, + job => job.RunAsync(CancellationToken.None), + Cron.Hourly()); } app.Run(); diff --git a/backend/src/Loopless.Application/Common/Exceptions/AccountSetupIncompleteException.cs b/backend/src/Loopless.Application/Common/Exceptions/AccountSetupIncompleteException.cs new file mode 100644 index 0000000..9edd88d --- /dev/null +++ b/backend/src/Loopless.Application/Common/Exceptions/AccountSetupIncompleteException.cs @@ -0,0 +1,9 @@ +namespace Loopless.Application.Common.Exceptions; + +/// +/// Keycloak rejected a password grant with "Account is not fully set up": the +/// credentials were CORRECT (Keycloak validates them first) but a required +/// action (UPDATE_PASSWORD, VERIFY_EMAIL, …) is pending. Callers inspect the +/// user's required actions to decide how to proceed. +/// +public sealed class AccountSetupIncompleteException(string message) : Exception(message); diff --git a/backend/src/Loopless.Application/Common/Exceptions/PasswordChangeRequiredException.cs b/backend/src/Loopless.Application/Common/Exceptions/PasswordChangeRequiredException.cs new file mode 100644 index 0000000..82f1ca9 --- /dev/null +++ b/backend/src/Loopless.Application/Common/Exceptions/PasswordChangeRequiredException.cs @@ -0,0 +1,9 @@ +namespace Loopless.Application.Common.Exceptions; + +/// +/// The account's credentials are valid but Keycloak requires a password change +/// (UPDATE_PASSWORD required action) before tokens can be issued. Surfaced to +/// the client as 409 + code "password_change_required" so the login page can +/// switch to the change-password form. +/// +public sealed class PasswordChangeRequiredException(string message) : Exception(message); diff --git a/backend/src/Loopless.Application/Common/Mapping/UserMapper.cs b/backend/src/Loopless.Application/Common/Mapping/UserMapper.cs index b65b222..23ab36d 100644 --- a/backend/src/Loopless.Application/Common/Mapping/UserMapper.cs +++ b/backend/src/Loopless.Application/Common/Mapping/UserMapper.cs @@ -16,8 +16,10 @@ namespace Loopless.Application.Common.Mapping; public static partial class UserMapper { [MapperIgnoreSource(nameof(User.KeycloakId))] + [MapperIgnoreSource(nameof(User.Username))] [MapperIgnoreSource(nameof(User.IsDeleted))] [MapperIgnoreSource(nameof(User.IsSuspended))] + [MapperIgnoreSource(nameof(User.SuspendedUntil))] [MapperIgnoreSource(nameof(User.EmailNotificationsEnabled))] [MapperIgnoreSource(nameof(User.FreelancerProfile))] [MapperIgnoreSource(nameof(User.EnterpriseProfile))] @@ -26,6 +28,7 @@ public static partial class UserMapper public static partial UserDto ToDto(this User user); [MapperIgnoreSource(nameof(User.KeycloakId))] + [MapperIgnoreSource(nameof(User.Username))] [MapperIgnoreSource(nameof(User.IsDeleted))] [MapperIgnoreSource(nameof(User.IsSuspended))] [MapperIgnoreSource(nameof(User.EmailNotificationsEnabled))] diff --git a/backend/src/Loopless.Application/Common/Security/ProjectSlugGenerator.cs b/backend/src/Loopless.Application/Common/Security/ProjectSlugGenerator.cs new file mode 100644 index 0000000..a94a24e --- /dev/null +++ b/backend/src/Loopless.Application/Common/Security/ProjectSlugGenerator.cs @@ -0,0 +1,56 @@ +using System.Text.RegularExpressions; +using Loopless.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Common.Security; + +/// +/// Generates unique, URL-safe slugs from project titles ("Website Redesign" → +/// "website-redesign", collisions get "-2", "-3", …). Used at project creation +/// and by the backfill migration's runtime equivalent — keep the slug rules in +/// sync with 20260613xxxxxx_AddProjectSlug. +/// +public static partial class ProjectSlugGenerator +{ + // Route segments that must never resolve as a project slug, because the + // frontend (/projects/new) or the API (/api/v1/projects/{me,discover}) own + // these literals and would shadow a project with the matching slug. + private static readonly string[] Reserved = + [ + "new", "me", "mine", "discover", "all", "projects", + ]; + + private const int MaxBaseLength = 50; // leaves room for "-NNN" suffixes within 60 chars + + [GeneratedRegex("[^a-z0-9]+")] + private static partial Regex NonAlphanumericRuns(); + + public static string Slugify(string title) + { + var slug = NonAlphanumericRuns().Replace(title.ToLowerInvariant(), "-").Trim('-'); + if (slug.Length > MaxBaseLength) + slug = slug[..MaxBaseLength].TrimEnd('-'); + if (slug.Length == 0 || Reserved.Contains(slug)) + slug = "project"; + return slug; + } + + public static async Task GenerateUniqueAsync( + IAppDbContext db, + string title, + CancellationToken cancellationToken = default) + { + var slug = Slugify(title); + var candidate = slug; + var suffix = 2; + + // Soft-deleted projects keep their slug, so check unfiltered. + while (await db.Projects.IgnoreQueryFilters() + .AnyAsync(p => p.Slug == candidate, cancellationToken)) + { + candidate = $"{slug}-{suffix++}"; + } + + return candidate; + } +} diff --git a/backend/src/Loopless.Application/Common/Security/SuspensionGuard.cs b/backend/src/Loopless.Application/Common/Security/SuspensionGuard.cs new file mode 100644 index 0000000..fb6eb0a --- /dev/null +++ b/backend/src/Loopless.Application/Common/Security/SuspensionGuard.cs @@ -0,0 +1,32 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Domain.Entities; + +namespace Loopless.Application.Common.Security; + +public static class SuspensionGuard +{ + /// + /// Enforces account suspension on a tracked . An expired + /// suspension window is lifted lazily — this makes expiry effective at the + /// next login/request even between SuspensionExpiryJob runs. Returns true + /// when the suspension was lifted (the caller must SaveChanges); throws + /// while the suspension is still active. + /// + public static bool EnsureNotSuspended(User user) + { + if (!user.IsSuspended) return false; + + var now = DateTimeOffset.UtcNow; + if (user.SuspendedUntil is { } until && until <= now) + { + user.IsSuspended = false; + user.SuspendedUntil = null; + user.UpdatedAt = now; + return true; + } + + throw new ForbiddenException(user.SuspendedUntil is { } end + ? $"Account suspended until {end:yyyy-MM-dd HH:mm} UTC." + : "Account suspended. Contact support."); + } +} diff --git a/backend/src/Loopless.Application/Common/Security/UsernameGenerator.cs b/backend/src/Loopless.Application/Common/Security/UsernameGenerator.cs new file mode 100644 index 0000000..70364af --- /dev/null +++ b/backend/src/Loopless.Application/Common/Security/UsernameGenerator.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; +using Loopless.Application.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Common.Security; + +/// +/// Generates unique, URL-safe usernames from display names ("Edi Asllani" → +/// "edi-asllani", collisions get "-2", "-3", …). Used at user creation and by +/// the backfill migration's runtime equivalent — keep the slug rules in sync +/// with 20260611110000_AddUserUsername. +/// +public static partial class UsernameGenerator +{ + // Route segments and pseudo-handles that must never resolve as a profile. + private static readonly string[] Reserved = + [ + "me", "admin", "user", "profile", "settings", "discover", + "messages", "projects", "help", "onboarding", "login", "signup", + ]; + + private const int MaxBaseLength = 26; // leaves room for "-NNN" suffixes within 30 chars + + [GeneratedRegex("[^a-z0-9]+")] + private static partial Regex NonAlphanumericRuns(); + + public static string Slugify(string name) + { + var slug = NonAlphanumericRuns().Replace(name.ToLowerInvariant(), "-").Trim('-'); + if (slug.Length > MaxBaseLength) + slug = slug[..MaxBaseLength].TrimEnd('-'); + if (slug.Length == 0 || Reserved.Contains(slug)) + slug = "user"; + return slug; + } + + public static async Task GenerateUniqueAsync( + IAppDbContext db, + string name, + CancellationToken cancellationToken = default) + { + var slug = Slugify(name); + var candidate = slug; + var suffix = 2; + + // Soft-deleted users keep their username, so check unfiltered. + while (await db.Users.IgnoreQueryFilters() + .AnyAsync(u => u.Username == candidate, cancellationToken)) + { + candidate = $"{slug}-{suffix++}"; + } + + return candidate; + } +} diff --git a/backend/src/Loopless.Application/DTOs/AdminUserDto.cs b/backend/src/Loopless.Application/DTOs/AdminUserDto.cs index 3a9b4f9..a052311 100644 --- a/backend/src/Loopless.Application/DTOs/AdminUserDto.cs +++ b/backend/src/Loopless.Application/DTOs/AdminUserDto.cs @@ -11,4 +11,5 @@ public sealed record AdminUserDto( DateTimeOffset JoinedAt, DateTimeOffset? LastActivityAt, bool IsSuspended, + DateTimeOffset? SuspendedUntil, bool IsDeleted); diff --git a/backend/src/Loopless.Application/DTOs/DiscoverProjectDto.cs b/backend/src/Loopless.Application/DTOs/DiscoverProjectDto.cs index 50a449c..c3fc42e 100644 --- a/backend/src/Loopless.Application/DTOs/DiscoverProjectDto.cs +++ b/backend/src/Loopless.Application/DTOs/DiscoverProjectDto.cs @@ -2,6 +2,7 @@ namespace Loopless.Application.DTOs; public sealed record DiscoverProjectDto( Guid Id, + string Slug, string Title, string? Description, string OwnerName, diff --git a/backend/src/Loopless.Application/DTOs/MatchDto.cs b/backend/src/Loopless.Application/DTOs/MatchDto.cs index 6f4e6dd..7d26850 100644 --- a/backend/src/Loopless.Application/DTOs/MatchDto.cs +++ b/backend/src/Loopless.Application/DTOs/MatchDto.cs @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs; public sealed record MatchDto( Guid UserId, + string Username, string Name, string? AvatarUrl, UserRole Role, diff --git a/backend/src/Loopless.Application/DTOs/ProjectDto.cs b/backend/src/Loopless.Application/DTOs/ProjectDto.cs index 5dda34f..3f8c8aa 100644 --- a/backend/src/Loopless.Application/DTOs/ProjectDto.cs +++ b/backend/src/Loopless.Application/DTOs/ProjectDto.cs @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs; public sealed record ProjectDto( Guid Id, + string Slug, Guid FreelancerId, string FreelancerName, string Title, diff --git a/backend/src/Loopless.Application/DTOs/PublicUserDto.cs b/backend/src/Loopless.Application/DTOs/PublicUserDto.cs index 0601f36..9efdefe 100644 --- a/backend/src/Loopless.Application/DTOs/PublicUserDto.cs +++ b/backend/src/Loopless.Application/DTOs/PublicUserDto.cs @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs; public sealed record PublicUserDto( Guid Id, + string Username, string Name, UserRole Role, string? AvatarUrl, diff --git a/backend/src/Loopless.Application/Features/Admin/ChangeUserRole/ChangeUserRoleCommandHandler.cs b/backend/src/Loopless.Application/Features/Admin/ChangeUserRole/ChangeUserRoleCommandHandler.cs index 357cac1..383eca8 100644 --- a/backend/src/Loopless.Application/Features/Admin/ChangeUserRole/ChangeUserRoleCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Admin/ChangeUserRole/ChangeUserRoleCommandHandler.cs @@ -66,6 +66,7 @@ public async Task Handle(ChangeUserRoleCommand request, Cancellati target.CreatedAt, null, target.IsSuspended, + target.SuspendedUntil, target.IsDeleted); } } diff --git a/backend/src/Loopless.Application/Features/Admin/GetUsers/GetUsersQueryHandler.cs b/backend/src/Loopless.Application/Features/Admin/GetUsers/GetUsersQueryHandler.cs index fcaddf2..b339e29 100644 --- a/backend/src/Loopless.Application/Features/Admin/GetUsers/GetUsersQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Admin/GetUsers/GetUsersQueryHandler.cs @@ -47,6 +47,7 @@ public async Task> Handle( .Where(n => n.UserId == u.Id) .Max(n => (DateTimeOffset?)n.CreatedAt), u.IsSuspended, + u.SuspendedUntil, u.IsDeleted, }) .ToListAsync(cancellationToken); @@ -61,6 +62,7 @@ public async Task> Handle( u.CreatedAt, new DateTimeOffset?[] { u.MessageLast, u.StandupLast, u.NotificationLast }.Max(), u.IsSuspended, + u.SuspendedUntil, u.IsDeleted)) .ToList(); diff --git a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommand.cs b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommand.cs index c7e25f6..106a924 100644 --- a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommand.cs +++ b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommand.cs @@ -3,5 +3,6 @@ namespace Loopless.Application.Features.Admin.SuspendUser; -public sealed record SuspendUserCommand(Guid UserId, bool Suspend) +/// End of the suspension window; null with Suspend=true means indefinite. +public sealed record SuspendUserCommand(Guid UserId, bool Suspend, DateTimeOffset? Until = null) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandHandler.cs b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandHandler.cs index 2f24a61..2bacd42 100644 --- a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandHandler.cs @@ -35,10 +35,11 @@ public async Task Handle( if (target.IsDeleted) throw new ConflictException("Cannot modify a deleted user."); - var oldValues = new { isSuspended = target.IsSuspended }; + var oldValues = new { isSuspended = target.IsSuspended, suspendedUntil = target.SuspendedUntil }; target.IsSuspended = request.Suspend; + target.SuspendedUntil = request.Suspend ? request.Until : null; target.UpdatedAt = DateTimeOffset.UtcNow; - var newValues = new { isSuspended = target.IsSuspended }; + var newValues = new { isSuspended = target.IsSuspended, suspendedUntil = target.SuspendedUntil }; db.AuditTrails.Add(new AuditTrail { @@ -61,6 +62,7 @@ public async Task Handle( target.CreatedAt, null, target.IsSuspended, + target.SuspendedUntil, target.IsDeleted); } } diff --git a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandValidator.cs b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandValidator.cs index b5c0acb..b53c29c 100644 --- a/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandValidator.cs +++ b/backend/src/Loopless.Application/Features/Admin/SuspendUser/SuspendUserCommandValidator.cs @@ -7,5 +7,15 @@ public sealed class SuspendUserCommandValidator : AbstractValidator x.UserId).NotEmpty(); + + RuleFor(x => x.Until) + .Must(until => until is null || until > DateTimeOffset.UtcNow) + .When(x => x.Suspend) + .WithMessage("Suspension end date must be in the future."); + + RuleFor(x => x.Until) + .Null() + .When(x => !x.Suspend) + .WithMessage("Until is only valid when suspending."); } } diff --git a/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommand.cs b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommand.cs new file mode 100644 index 0000000..ac7e606 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommand.cs @@ -0,0 +1,15 @@ +using Loopless.Application.DTOs; +using MediatR; + +namespace Loopless.Application.Features.Auth.ForceChangePassword; + +/// +/// Anonymous change-password used from the login page after an admin forced a +/// reset (Keycloak UPDATE_PASSWORD required action). The current password is +/// verified against Keycloak before anything changes; on success the user is +/// logged straight in with the new credentials. +/// +public sealed record ForceChangePasswordCommand( + string Email, + string CurrentPassword, + string NewPassword) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandHandler.cs b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandHandler.cs new file mode 100644 index 0000000..b186b4c --- /dev/null +++ b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandHandler.cs @@ -0,0 +1,74 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Json; +using Loopless.Application.Common.Security; +using Loopless.Application.DTOs; +using Loopless.Application.Interfaces; +using Loopless.Domain.Entities; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Auth.ForceChangePassword; + +public sealed class ForceChangePasswordCommandHandler( + IAppDbContext db, + IKeycloakAdminClient keycloak) : IRequestHandler +{ + public async Task Handle(ForceChangePasswordCommand request, CancellationToken cancellationToken) + { + var email = request.Email.Trim().ToLowerInvariant(); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.Email == email, cancellationToken) + // Same message as a wrong password so the endpoint can't be used to + // probe which emails exist. + ?? throw new BadRequestException("Current password is incorrect."); + + // Verify the CURRENT password via a password grant. Keycloak validates + // credentials before required actions, so: + // - success -> password correct, no action pending + // - AccountSetupIncompleteException -> password correct, action pending + // - UnauthorizedException -> wrong password + try + { + await keycloak.PasswordGrantAsync(email, request.CurrentPassword, cancellationToken); + } + catch (AccountSetupIncompleteException) + { + var actions = await keycloak.GetRequiredActionsAsync(user.KeycloakId, cancellationToken); + if (!actions.Contains("UPDATE_PASSWORD", StringComparer.OrdinalIgnoreCase)) + throw new UnauthorizedException("Account setup incomplete. Contact support."); + } + catch (UnauthorizedException) + { + // 400, not 401 — a 401 would trip the frontend token-refresh interceptor. + throw new BadRequestException("Current password is incorrect."); + } + + // A suspension must not be bypassable through the change-password flow. + if (SuspensionGuard.EnsureNotSuspended(user)) + await db.SaveChangesAsync(cancellationToken); + + await keycloak.ResetPasswordAsync(user.KeycloakId, request.NewPassword, cancellationToken); + await keycloak.ClearRequiredActionAsync(user.KeycloakId, "UPDATE_PASSWORD", cancellationToken); + + db.AuditTrails.Add(new AuditTrail + { + ActorUserId = user.Id, + Action = "User.PasswordChange", + EntityType = "User", + EntityId = user.Id, + OldValues = null, + NewValues = AuditJson.Serialize(new { method = "forced-change-at-login" }), + }); + await db.SaveChangesAsync(cancellationToken); + + // Log the user straight in with the new credentials. + var token = await keycloak.PasswordGrantAsync(email, request.NewPassword, cancellationToken); + + return new AuthResponse( + token.AccessToken, + token.ExpiresIn, + token.RefreshToken, + new UserDto(user.Id, user.Email, user.Name, user.Role, user.AvatarUrl)); + } +} diff --git a/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandValidator.cs b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandValidator.cs new file mode 100644 index 0000000..8dbb4e8 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Auth/ForceChangePassword/ForceChangePasswordCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; + +namespace Loopless.Application.Features.Auth.ForceChangePassword; + +public sealed class ForceChangePasswordCommandValidator : AbstractValidator +{ + public ForceChangePasswordCommandValidator() + { + RuleFor(x => x.Email) + .NotEmpty() + .EmailAddress(); + + RuleFor(x => x.CurrentPassword) + .NotEmpty(); + + // Mirrors Settings.ChangePassword policy. + RuleFor(x => x.NewPassword) + .NotEmpty() + .MinimumLength(8).WithMessage("Password must be at least 8 characters.") + .Matches("[A-Z]").WithMessage("Password must contain an uppercase letter.") + .Matches("[a-z]").WithMessage("Password must contain a lowercase letter.") + .Matches("[0-9]").WithMessage("Password must contain a number.") + .NotEqual(x => x.CurrentPassword).WithMessage("New password must differ from the current one."); + } +} diff --git a/backend/src/Loopless.Application/Features/Auth/GetCurrentUser/GetCurrentUserQueryHandler.cs b/backend/src/Loopless.Application/Features/Auth/GetCurrentUser/GetCurrentUserQueryHandler.cs index eced767..bc5e02c 100644 --- a/backend/src/Loopless.Application/Features/Auth/GetCurrentUser/GetCurrentUserQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Auth/GetCurrentUser/GetCurrentUserQueryHandler.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using MediatR; @@ -17,10 +18,12 @@ public async Task Handle(GetCurrentUserQuery request, CancellationToken ?? throw new UnauthorizedException("Missing subject claim."); var user = await db.Users - .AsNoTracking() .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) ?? throw new NotFoundException("User not found."); + if (SuspensionGuard.EnsureNotSuspended(user)) + await db.SaveChangesAsync(cancellationToken); + return new UserDto(user.Id, user.Email, user.Name, user.Role, user.AvatarUrl); } } diff --git a/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs b/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs index 8eb1a61..6849df3 100644 --- a/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using Loopless.Domain.Entities; @@ -62,6 +63,9 @@ public async Task Handle(GitHubSsoCommand request, CancellationTok { Email = email, Name = name, + // Prefer the GitHub handle as the base for the profile username. + Username = await UsernameGenerator.GenerateUniqueAsync( + db, userInfo.PreferredUsername ?? name, cancellationToken), Role = UserRole.Freelancer, KeycloakId = userInfo.Sub, AvatarUrl = userInfo.PreferredUsername is not null diff --git a/backend/src/Loopless.Application/Features/Auth/Login/LoginQueryHandler.cs b/backend/src/Loopless.Application/Features/Auth/Login/LoginQueryHandler.cs index 3038657..c7e2e2c 100644 --- a/backend/src/Loopless.Application/Features/Auth/Login/LoginQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Auth/Login/LoginQueryHandler.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using MediatR; @@ -14,13 +15,38 @@ public sealed class LoginQueryHandler( public async Task Handle(LoginQuery request, CancellationToken cancellationToken) { var email = request.Email.Trim().ToLowerInvariant(); - var token = await keycloak.PasswordGrantAsync(email, request.Password, cancellationToken); + // Credentials are verified first so the suspension state of an account is + // only revealed to someone who knows its password. + TokenResponse token; + try + { + token = await keycloak.PasswordGrantAsync(email, request.Password, cancellationToken); + } + catch (AccountSetupIncompleteException) + { + // Correct password, but Keycloak demands a required action first. When it's + // UPDATE_PASSWORD (admin-forced reset) the login page switches to its + // change-password form via this dedicated error code. + var pending = await db.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Email == email, cancellationToken) + ?? throw new UnauthorizedException("Invalid email or password."); + + var actions = await keycloak.GetRequiredActionsAsync(pending.KeycloakId, cancellationToken); + if (actions.Contains("UPDATE_PASSWORD", StringComparer.OrdinalIgnoreCase)) + throw new PasswordChangeRequiredException( + "A password change is required before you can log in."); + + throw new UnauthorizedException("Account setup incomplete. Contact support."); + } var user = await db.Users - .AsNoTracking() .FirstOrDefaultAsync(u => u.Email == email, cancellationToken) ?? throw new NotFoundException("User mirror row missing. Contact support."); + if (SuspensionGuard.EnsureNotSuspended(user)) + await db.SaveChangesAsync(cancellationToken); + return new AuthResponse( token.AccessToken, token.ExpiresIn, diff --git a/backend/src/Loopless.Application/Features/Auth/Register/RegisterCommandHandler.cs b/backend/src/Loopless.Application/Features/Auth/Register/RegisterCommandHandler.cs index bb51569..5ea74ee 100644 --- a/backend/src/Loopless.Application/Features/Auth/Register/RegisterCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Auth/Register/RegisterCommandHandler.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using Loopless.Domain.Entities; @@ -33,6 +34,7 @@ public async Task Handle(RegisterCommand request, CancellationToke { Email = emailNormalized, Name = request.Name, + Username = await UsernameGenerator.GenerateUniqueAsync(db, request.Name, cancellationToken), Role = request.Role, KeycloakId = keycloakId, }; diff --git a/backend/src/Loopless.Application/Features/Files/Upload/UploadFileCommandValidator.cs b/backend/src/Loopless.Application/Features/Files/Upload/UploadFileCommandValidator.cs index 71ef96d..0eda460 100644 --- a/backend/src/Loopless.Application/Features/Files/Upload/UploadFileCommandValidator.cs +++ b/backend/src/Loopless.Application/Features/Files/Upload/UploadFileCommandValidator.cs @@ -11,10 +11,21 @@ public sealed class UploadFileCommandValidator : AbstractValidator x.FileName).NotEmpty().MaximumLength(255); RuleFor(x => x.FileSize).InclusiveBetween(1, MaxFileSizeBytes); RuleFor(x => x.ContentType) - .NotEmpty() .Must(IsAllowedContentType) .WithMessage("Unsupported file type."); RuleFor(x => x.FileName) @@ -41,8 +57,13 @@ public UploadFileCommandValidator() .WithMessage("Unsupported file extension."); } - private static bool IsAllowedContentType(string contentType) - => AllowedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase); + private static bool IsAllowedContentType(string? contentType) + { + // No/generic content type: defer to the mandatory extension check. + if (string.IsNullOrWhiteSpace(contentType)) return true; + if (string.Equals(contentType, "application/octet-stream", StringComparison.OrdinalIgnoreCase)) return true; + return AllowedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase); + } private static bool HasAllowedExtension(string fileName) { diff --git a/backend/src/Loopless.Application/Features/Matching/Discover/DiscoverMatchesQueryHandler.cs b/backend/src/Loopless.Application/Features/Matching/Discover/DiscoverMatchesQueryHandler.cs index 2b83a40..93d2cb0 100644 --- a/backend/src/Loopless.Application/Features/Matching/Discover/DiscoverMatchesQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Matching/Discover/DiscoverMatchesQueryHandler.cs @@ -1,4 +1,4 @@ -using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Exceptions; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using Loopless.Domain.Enums; @@ -84,7 +84,7 @@ public async Task> Handle(DiscoverMatchesQuery request, Ca var users = await db.Users .Where(u => entityIds.Contains(u.Id)) - .Select(u => new { u.Id, u.Name, u.AvatarUrl, u.Role }) + .Select(u => new { u.Id, u.Username, u.Name, u.AvatarUrl, u.Role }) .ToListAsync(cancellationToken); Dictionary> skillsDict; @@ -121,10 +121,11 @@ public async Task> Handle(DiscoverMatchesQuery request, Ca var u = usersDict[s.EntityId]; var matchScore = Math.Round(Math.Max(0d, Math.Min(100d, (1 - s.Distance) * 100)), 1); var topSkills = skillsDict.GetValueOrDefault(s.EntityId, []); - return new MatchDto(u.Id, u.Name, u.AvatarUrl, u.Role, topSkills, matchScore); + return new MatchDto(u.Id, u.Username, u.Name, u.AvatarUrl, u.Role, topSkills, matchScore); }) .ToList(); return new PagedResult(matches, totalCount, request.Page, request.PageSize); } } + diff --git a/backend/src/Loopless.Application/Features/Matching/Search/SemanticSearchQueryHandler.cs b/backend/src/Loopless.Application/Features/Matching/Search/SemanticSearchQueryHandler.cs index 91defd1..9e146d8 100644 --- a/backend/src/Loopless.Application/Features/Matching/Search/SemanticSearchQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Matching/Search/SemanticSearchQueryHandler.cs @@ -1,4 +1,4 @@ -using Loopless.Application.DTOs; +using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using Loopless.Domain.Entities; using Loopless.Domain.Enums; @@ -54,7 +54,7 @@ public async Task> Handle(SemanticSearchQuery request, Can var users = await db.Users .Where(u => entityIds.Contains(u.Id)) - .Select(u => new { u.Id, u.Name, u.AvatarUrl, u.Role }) + .Select(u => new { u.Id, u.Username, u.Name, u.AvatarUrl, u.Role }) .ToListAsync(cancellationToken); var freelancerIds = scored @@ -100,10 +100,11 @@ public async Task> Handle(SemanticSearchQuery request, Can var u = usersDict[s.EntityId]; var matchScore = Math.Round(Math.Max(0d, Math.Min(100d, (1 - s.Distance) * 100)), 1); var topSkills = skillsDict.GetValueOrDefault(s.EntityId, []); - return new MatchDto(u.Id, u.Name, u.AvatarUrl, u.Role, topSkills, matchScore); + return new MatchDto(u.Id, u.Username, u.Name, u.AvatarUrl, u.Role, topSkills, matchScore); }) .ToList(); return new PagedResult(matches, totalCount, request.Page, request.PageSize); } } + diff --git a/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs index 7a1bf3a..cf16cd4 100644 --- a/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Messages/Send/SendMessageCommandHandler.cs @@ -10,7 +10,8 @@ namespace Loopless.Application.Features.Messages.Send; public sealed class SendMessageCommandHandler( IAppDbContext db, ICurrentUser currentUser, - IMessagingNotifier notifier) : IRequestHandler + IMessagingNotifier notifier, + INotificationService notifications) : IRequestHandler { public async Task Handle(SendMessageCommand request, CancellationToken cancellationToken) { @@ -57,6 +58,28 @@ public async Task Handle(SendMessageCommand request, CancellationTok await notifier.NotifyMessageSentAsync(dto, recipientId, cancellationToken); + // Bell notification for the recipient. One unread entry per conversation is + // enough — a burst of messages must not flood the bell, so skip when an + // unread notification for this conversation already exists. + var actionUrl = $"/messages?c={conversation.Id}"; + var hasUnread = await db.Notifications.AnyAsync( + n => n.UserId == recipientId && n.ActionUrl == actionUrl && !n.IsRead, + cancellationToken); + + if (!hasUnread) + { + var preview = request.Content.Length > 100 + ? request.Content[..97] + "..." + : request.Content; + + await notifications.SendAsync( + recipientId, + $"New message from {me.Name}", + preview, + cancellationToken, + actionUrl); + } + return dto; } } diff --git a/backend/src/Loopless.Application/Features/Moderation/FlagContent/FlagContentCommandHandler.cs b/backend/src/Loopless.Application/Features/Moderation/FlagContent/FlagContentCommandHandler.cs index a5b72cc..b1697bb 100644 --- a/backend/src/Loopless.Application/Features/Moderation/FlagContent/FlagContentCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Moderation/FlagContent/FlagContentCommandHandler.cs @@ -5,13 +5,15 @@ using Loopless.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Loopless.Application.Features.Moderation.FlagContent; public sealed class FlagContentCommandHandler( IAppDbContext db, ICurrentUser currentUser, - INotificationService notifications) : IRequestHandler + INotificationService notifications, + ILogger logger) : IRequestHandler { public async Task Handle(FlagContentCommand request, CancellationToken cancellationToken) { @@ -48,6 +50,14 @@ await notifications.SendAsync( "/admin/flags"); } + // Zero admins means notifications silently go nowhere (e.g. the admin + // account only has the role in Keycloak, not in the app DB) — make that + // diagnosable from logs. + if (adminIds.Count == 0) + logger.LogWarning("Content flag {FlagId}: no admin users found to notify", flag.Id); + else + logger.LogInformation("Content flag {FlagId}: notified {AdminCount} admins", flag.Id, adminIds.Count); + return new ContentFlagDto( flag.Id, reporter.Id, diff --git a/backend/src/Loopless.Application/Features/Profile/UpdateEnterpriseProfile/UpdateEnterpriseProfileCommandHandler.cs b/backend/src/Loopless.Application/Features/Profile/UpdateEnterpriseProfile/UpdateEnterpriseProfileCommandHandler.cs index c56bdc9..377c4a3 100644 --- a/backend/src/Loopless.Application/Features/Profile/UpdateEnterpriseProfile/UpdateEnterpriseProfileCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Profile/UpdateEnterpriseProfile/UpdateEnterpriseProfileCommandHandler.cs @@ -47,6 +47,7 @@ public async Task Handle(UpdateEnterpriseProfileCommand request, return new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, diff --git a/backend/src/Loopless.Application/Features/Profile/UpdateFreelancerProfile/UpdateFreelancerProfileCommandHandler.cs b/backend/src/Loopless.Application/Features/Profile/UpdateFreelancerProfile/UpdateFreelancerProfileCommandHandler.cs index 317fb18..ef76c77 100644 --- a/backend/src/Loopless.Application/Features/Profile/UpdateFreelancerProfile/UpdateFreelancerProfileCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Profile/UpdateFreelancerProfile/UpdateFreelancerProfileCommandHandler.cs @@ -49,6 +49,7 @@ public async Task Handle(UpdateFreelancerProfileCommand request, return new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, diff --git a/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs b/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs index 3c18a7c..ef2024e 100644 --- a/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Profile/UploadAvatar/UploadAvatarCommandHandler.cs @@ -47,6 +47,7 @@ await storage.UploadAsync( { UserRole.Freelancer when fp is not null => new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, @@ -64,6 +65,7 @@ await storage.UploadAsync( UserRole.Enterprise when ep is not null => new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, @@ -80,6 +82,7 @@ await storage.UploadAsync( _ => new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, diff --git a/backend/src/Loopless.Application/Features/Projects/Archive/ArchiveProjectCommandHandler.cs b/backend/src/Loopless.Application/Features/Projects/Archive/ArchiveProjectCommandHandler.cs index d04e17d..a3fae49 100644 --- a/backend/src/Loopless.Application/Features/Projects/Archive/ArchiveProjectCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/Archive/ArchiveProjectCommandHandler.cs @@ -45,6 +45,7 @@ public async Task Handle(ArchiveProjectCommand request, Cancellation return new ProjectDto( project.Id, + project.Slug, project.OwnerId, user.Name, project.Title, diff --git a/backend/src/Loopless.Application/Features/Projects/Create/CreateProjectCommandHandler.cs b/backend/src/Loopless.Application/Features/Projects/Create/CreateProjectCommandHandler.cs index df4dc60..48cc0fe 100644 --- a/backend/src/Loopless.Application/Features/Projects/Create/CreateProjectCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/Create/CreateProjectCommandHandler.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.DTOs; using Loopless.Application.Interfaces; using Loopless.Domain.Entities; @@ -28,6 +29,7 @@ public async Task Handle(CreateProjectCommand request, CancellationT { OwnerId = user.Id, Title = request.Title.Trim(), + Slug = await ProjectSlugGenerator.GenerateUniqueAsync(db, request.Title, cancellationToken), Description = request.Description?.Trim(), GitHubRepoUrl = string.IsNullOrWhiteSpace(request.GitHubRepoUrl) ? null @@ -45,6 +47,7 @@ public async Task Handle(CreateProjectCommand request, CancellationT return new ProjectDto( project.Id, + project.Slug, project.OwnerId, user.Name, project.Title, diff --git a/backend/src/Loopless.Application/Features/Projects/Discover/DiscoverProjectsQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/Discover/DiscoverProjectsQueryHandler.cs index 1a36364..1e53503 100644 --- a/backend/src/Loopless.Application/Features/Projects/Discover/DiscoverProjectsQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/Discover/DiscoverProjectsQueryHandler.cs @@ -46,6 +46,7 @@ public async Task> Handle( .Join(db.Users, p => p.OwnerId, u => u.Id, (p, owner) => new { p.Id, + p.Slug, p.Title, p.Description, p.RequiredSkills, @@ -91,6 +92,7 @@ public async Task> Handle( return new DiscoverProjectDto( p.Id, + p.Slug, p.Title, p.Description, p.OwnerName, diff --git a/backend/src/Loopless.Application/Features/Projects/GetAll/GetProjectsQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetAll/GetProjectsQueryHandler.cs index 0fec8de..f9d8a82 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetAll/GetProjectsQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetAll/GetProjectsQueryHandler.cs @@ -48,6 +48,7 @@ public async Task> Handle(GetProjectsQuery request, Ca .OrderByDescending(p => p.CreatedAt) .Join(db.Users, p => p.OwnerId, u => u.Id, (p, u) => new ProjectDto( p.Id, + p.Slug, p.OwnerId, u.Name, p.Title, diff --git a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQuery.cs b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQuery.cs index 63830d1..e085527 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQuery.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQuery.cs @@ -3,4 +3,7 @@ namespace Loopless.Application.Features.Projects.GetDashboard; -public sealed record GetProjectDashboardQuery(Guid ProjectId) : IRequest; +// Accepts either the project's GUID or its URL slug. GUID lookups remain +// supported so pre-slug links (e.g. old notifications) keep resolving; the +// frontend canonicalizes them to /projects/{slug} after load. +public sealed record GetProjectDashboardQuery(string ProjectIdOrSlug) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs index 0c1f752..a221617 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryHandler.cs @@ -20,8 +20,10 @@ public async Task Handle(GetProjectDashboardQuery request, .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) ?? throw new NotFoundException("User not found."); - var project = await db.Projects - .FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken) + var project = await (Guid.TryParse(request.ProjectIdOrSlug, out var projectId) + ? db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, cancellationToken) + : db.Projects.FirstOrDefaultAsync( + p => p.Slug == request.ProjectIdOrSlug.ToLower(), cancellationToken)) ?? throw new NotFoundException("Project not found."); var isOwner = project.OwnerId == user.Id; @@ -41,6 +43,7 @@ public async Task Handle(GetProjectDashboardQuery request, var projectDto = new ProjectDto( project.Id, + project.Slug, project.OwnerId, ownerName, project.Title, diff --git a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryValidator.cs b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryValidator.cs index 3a06954..7d4009f 100644 --- a/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryValidator.cs +++ b/backend/src/Loopless.Application/Features/Projects/GetDashboard/GetProjectDashboardQueryValidator.cs @@ -6,6 +6,6 @@ public sealed class GetProjectDashboardQueryValidator : AbstractValidator x.ProjectId).NotEmpty(); + RuleFor(x => x.ProjectIdOrSlug).NotEmpty(); } } diff --git a/backend/src/Loopless.Application/Features/Projects/RemoveMember/RemoveProjectMemberCommandHandler.cs b/backend/src/Loopless.Application/Features/Projects/RemoveMember/RemoveProjectMemberCommandHandler.cs index dad6f67..f93504e 100644 --- a/backend/src/Loopless.Application/Features/Projects/RemoveMember/RemoveProjectMemberCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Projects/RemoveMember/RemoveProjectMemberCommandHandler.cs @@ -49,7 +49,7 @@ await notifications.SendAsync( "Removed from Project", $"You have been removed from \"{project.Title}\". Reason: {reason}", cancellationToken, - $"/projects/{project.Id}"); + $"/projects/{project.Slug}"); await events.PublishAsync("member.removed", new { diff --git a/backend/src/Loopless.Application/Features/Support/CreateSupportTicket/CreateSupportTicketCommandHandler.cs b/backend/src/Loopless.Application/Features/Support/CreateSupportTicket/CreateSupportTicketCommandHandler.cs index 252d3b9..18db555 100644 --- a/backend/src/Loopless.Application/Features/Support/CreateSupportTicket/CreateSupportTicketCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Support/CreateSupportTicket/CreateSupportTicketCommandHandler.cs @@ -5,13 +5,15 @@ using Loopless.Domain.Enums; using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Loopless.Application.Features.Support.CreateSupportTicket; public sealed class CreateSupportTicketCommandHandler( IAppDbContext db, ICurrentUser currentUser, - INotificationService notifications) : IRequestHandler + INotificationService notifications, + ILogger logger) : IRequestHandler { public async Task Handle(CreateSupportTicketCommand request, CancellationToken cancellationToken) { @@ -48,6 +50,14 @@ await notifications.SendAsync( $"/admin/support"); } + // Zero admins means notifications silently go nowhere (e.g. the admin + // account only has the role in Keycloak, not in the app DB) — make that + // diagnosable from logs. + if (adminIds.Count == 0) + logger.LogWarning("Support ticket {TicketId}: no admin users found to notify", ticket.Id); + else + logger.LogInformation("Support ticket {TicketId}: notified {AdminCount} admins", ticket.Id, adminIds.Count); + return new SupportTicketDto( ticket.Id, reporter.Id, diff --git a/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQuery.cs b/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQuery.cs index c174fc4..0626658 100644 --- a/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQuery.cs +++ b/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQuery.cs @@ -1,6 +1,10 @@ -using Loopless.Application.DTOs; +using Loopless.Application.DTOs; using MediatR; namespace Loopless.Application.Features.Users.GetUserById; -public sealed record GetUserByIdQuery(Guid UserId) : IRequest; +/// +/// Looks up a public profile by GUID (legacy links) or by username — the +/// canonical, non-enumerable identifier used in /profile/{username} URLs. +/// +public sealed record GetUserByIdQuery(string IdOrUsername) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQueryHandler.cs b/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQueryHandler.cs index 8524315..bf63a45 100644 --- a/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQueryHandler.cs +++ b/backend/src/Loopless.Application/Features/Users/GetUserById/GetUserByIdQueryHandler.cs @@ -12,11 +12,17 @@ public sealed class GetUserByIdQueryHandler(IAppDbContext db) { public async Task Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { - var user = await db.Users + var query = db.Users .AsNoTracking() .Include(u => u.FreelancerProfile) - .Include(u => u.EnterpriseProfile) - .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken) + .Include(u => u.EnterpriseProfile); + + // GUID lookups remain supported so pre-username links keep resolving; + // the frontend canonicalizes them to /profile/{username} after load. + var user = await (Guid.TryParse(request.IdOrUsername, out var id) + ? query.FirstOrDefaultAsync(u => u.Id == id, cancellationToken) + : query.FirstOrDefaultAsync( + u => u.Username == request.IdOrUsername.ToLower(), cancellationToken)) ?? throw new NotFoundException("User not found."); int activeProjectCount; @@ -45,6 +51,7 @@ public async Task Handle(GetUserByIdQuery request, CancellationTo return new PublicUserDto( user.Id, + user.Username, user.Name, user.Role, user.AvatarUrl, diff --git a/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs b/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs index 1bf8850..fb64f9e 100644 --- a/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs +++ b/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs @@ -58,6 +58,18 @@ Task ResetPasswordAsync( string keycloakId, string newPassword, CancellationToken cancellationToken = default); + + /// Returns the user's pending Keycloak required actions + /// (e.g. "UPDATE_PASSWORD" after an admin-forced reset). + Task> GetRequiredActionsAsync( + string keycloakId, + CancellationToken cancellationToken = default); + + /// Removes a single required action from the user, keeping any others. + Task ClearRequiredActionAsync( + string keycloakId, + string action, + CancellationToken cancellationToken = default); } public sealed record TokenResponse( diff --git a/backend/src/Loopless.Domain/Entities/Project.cs b/backend/src/Loopless.Domain/Entities/Project.cs index 23c59e6..4c9f5be 100644 --- a/backend/src/Loopless.Domain/Entities/Project.cs +++ b/backend/src/Loopless.Domain/Entities/Project.cs @@ -7,6 +7,8 @@ public class Project : BaseEntity { public required Guid OwnerId { get; set; } public required string Title { get; set; } + /// Unique, URL-safe handle used in project URLs instead of the GUID. Stable once generated. + public string Slug { get; set; } = string.Empty; public string? Description { get; set; } public string? GitHubRepoUrl { get; set; } public string? GitHubWebhookSecret { get; set; } diff --git a/backend/src/Loopless.Domain/Entities/User.cs b/backend/src/Loopless.Domain/Entities/User.cs index c234a13..b759207 100644 --- a/backend/src/Loopless.Domain/Entities/User.cs +++ b/backend/src/Loopless.Domain/Entities/User.cs @@ -7,11 +7,15 @@ public class User : BaseEntity { public required string Email { get; set; } public required string Name { get; set; } + /// Unique, URL-safe handle used in public profile URLs instead of the GUID. + public string Username { get; set; } = string.Empty; public required UserRole Role { get; set; } public string? AvatarUrl { get; set; } public required string KeycloakId { get; set; } public bool IsDeleted { get; set; } public bool IsSuspended { get; set; } + /// End of the suspension window; null while suspended means indefinite. + public DateTimeOffset? SuspendedUntil { get; set; } public bool EmailNotificationsEnabled { get; set; } = true; public FreelancerProfile? FreelancerProfile { get; set; } diff --git a/backend/src/Loopless.Infrastructure/BackgroundJobs/SuspensionExpiryJob.cs b/backend/src/Loopless.Infrastructure/BackgroundJobs/SuspensionExpiryJob.cs new file mode 100644 index 0000000..fddab25 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/BackgroundJobs/SuspensionExpiryJob.cs @@ -0,0 +1,39 @@ +using Loopless.Application.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Loopless.Infrastructure.BackgroundJobs; + +/// +/// Lifts suspensions whose window has elapsed so admin lists stay accurate. +/// Login and /me also lift lazily (SuspensionGuard), so this job is a sweep, +/// not the only enforcement point. +/// +public sealed class SuspensionExpiryJob( + IAppDbContext db, + ILogger logger) +{ + public const string RecurringJobId = "suspension-expiry"; + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + + var expired = await db.Users + .IgnoreQueryFilters() + .Where(u => u.IsSuspended && u.SuspendedUntil != null && u.SuspendedUntil <= now) + .ToListAsync(cancellationToken); + + if (expired.Count == 0) return; + + foreach (var user in expired) + { + user.IsSuspended = false; + user.SuspendedUntil = null; + user.UpdatedAt = now; + } + + await db.SaveChangesAsync(cancellationToken); + logger.LogInformation("SuspensionExpiryJob: lifted {Count} expired suspensions", expired.Count); + } +} diff --git a/backend/src/Loopless.Infrastructure/DependencyInjection.cs b/backend/src/Loopless.Infrastructure/DependencyInjection.cs index 118fa82..5e04b01 100644 --- a/backend/src/Loopless.Infrastructure/DependencyInjection.cs +++ b/backend/src/Loopless.Infrastructure/DependencyInjection.cs @@ -90,6 +90,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); if (!string.IsNullOrEmpty(configuration.GetConnectionString("Redis"))) { diff --git a/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs b/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs index 06dacb8..58a2082 100644 --- a/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs +++ b/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs @@ -92,7 +92,16 @@ public async Task PasswordGrantAsync( using var res = await http.SendAsync(req, cancellationToken); if (res.StatusCode == HttpStatusCode.Unauthorized || res.StatusCode == HttpStatusCode.BadRequest) + { + // Keycloak validates the credentials BEFORE checking required actions, so + // "Account is not fully set up" means the password was correct but an + // action (e.g. UPDATE_PASSWORD after an admin-forced reset) is pending. + var errorBody = await res.Content.ReadAsStringAsync(cancellationToken); + if (errorBody.Contains("not fully set up", StringComparison.OrdinalIgnoreCase)) + throw new AccountSetupIncompleteException("Account setup incomplete."); + throw new UnauthorizedException("Invalid email or password."); + } if (!res.IsSuccessStatusCode) { @@ -111,6 +120,58 @@ public async Task PasswordGrantAsync( payload.TokenType); } + public async Task> GetRequiredActionsAsync( + string keycloakId, + CancellationToken cancellationToken = default) + { + var adminToken = await GetAdminTokenAsync(cancellationToken); + + using var req = new HttpRequestMessage(HttpMethod.Get, BuildAdminUri($"users/{keycloakId}")); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + using var res = await http.SendAsync(req, cancellationToken); + if (!res.IsSuccessStatusCode) + { + var body = await res.Content.ReadAsStringAsync(cancellationToken); + throw new KeycloakOperationException( + $"Failed to fetch user ({(int)res.StatusCode}): {body}"); + } + + var json = await res.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (!json.TryGetProperty("requiredActions", out var actions) || actions.ValueKind != JsonValueKind.Array) + return []; + + return [.. actions.EnumerateArray() + .Select(a => a.GetString()) + .Where(a => !string.IsNullOrEmpty(a)) + .Cast()]; + } + + public async Task ClearRequiredActionAsync( + string keycloakId, + string action, + CancellationToken cancellationToken = default) + { + var remaining = (await GetRequiredActionsAsync(keycloakId, cancellationToken)) + .Where(a => !string.Equals(a, action, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + var adminToken = await GetAdminTokenAsync(cancellationToken); + using var req = new HttpRequestMessage(HttpMethod.Put, BuildAdminUri($"users/{keycloakId}")) + { + Content = JsonContent.Create(new { requiredActions = remaining }), + }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + using var res = await http.SendAsync(req, cancellationToken); + if (!res.IsSuccessStatusCode) + { + var body = await res.Content.ReadAsStringAsync(cancellationToken); + throw new KeycloakOperationException( + $"Failed to clear required action '{action}' ({(int)res.StatusCode}): {body}"); + } + } + public async Task AuthorizationCodeGrantAsync( string code, string redirectUri, diff --git a/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs b/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs index 6ff2c0e..9080863 100644 --- a/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs +++ b/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs @@ -46,14 +46,21 @@ protected override async Task HandleMessageAsync(string routingKey, byte[] body, var initiatedBy = invitation?.InitiatedBy ?? "Enterprise"; + // Action URLs use the project slug (never the GUID) so notification + // links open the canonical /projects/{slug} address directly. + var projectRef = await db.Projects + .Where(p => p.Id == e.ProjectId) + .Select(p => p.Slug) + .FirstOrDefaultAsync(cancellationToken) ?? e.ProjectId.ToString(); + var (userId, title, message, actionUrl) = routingKey switch { "invitation.created" => initiatedBy == "Freelancer" - ? (e.EnterpriseId, "New Application", $"{e.FreelancerName} applied to your project \"{e.ProjectName}\"", (string?)$"/projects/{e.ProjectId}?tab=invitations") + ? (e.EnterpriseId, "New Application", $"{e.FreelancerName} applied to your project \"{e.ProjectName}\"", (string?)$"/projects/{projectRef}?tab=invitations") : (e.FreelancerId, "Project Invitation", $"{e.EnterpriseName} invited you to join \"{e.ProjectName}\"", (string?)"/projects"), "invitation.accepted" => initiatedBy == "Freelancer" - ? (e.FreelancerId, "Application Accepted", $"Your application to \"{e.ProjectName}\" was accepted.", (string?)$"/projects/{e.ProjectId}") - : (e.EnterpriseId, "Invitation Accepted", $"{e.FreelancerName} accepted your invitation to \"{e.ProjectName}\".", (string?)$"/projects/{e.ProjectId}"), + ? (e.FreelancerId, "Application Accepted", $"Your application to \"{e.ProjectName}\" was accepted.", (string?)$"/projects/{projectRef}") + : (e.EnterpriseId, "Invitation Accepted", $"{e.FreelancerName} accepted your invitation to \"{e.ProjectName}\".", (string?)$"/projects/{projectRef}"), _ => (Guid.Empty, string.Empty, string.Empty, (string?)null) }; diff --git a/backend/src/Loopless.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs b/backend/src/Loopless.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs index 4aafd21..deb5be0 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs @@ -14,6 +14,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(p => p.OwnerId).HasColumnName("FreelancerId").IsRequired(); builder.Property(p => p.Title).HasMaxLength(200).IsRequired(); + builder.Property(p => p.Slug).HasMaxLength(60).IsRequired(); builder.Property(p => p.Description).HasMaxLength(4000); builder.Property(p => p.GitHubRepoUrl).HasMaxLength(500); builder.Property(p => p.GitHubWebhookSecret).HasMaxLength(256); @@ -39,6 +40,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(p => p.OwnerId).HasDatabaseName("ix_projects_freelancer_id"); builder.HasIndex(p => p.Status); + builder.HasIndex(p => p.Slug).IsUnique(); builder.HasQueryFilter(p => !p.IsDeleted); } diff --git a/backend/src/Loopless.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/backend/src/Loopless.Infrastructure/Persistence/Configurations/UserConfiguration.cs index b4194f2..3472535 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -16,6 +16,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(u => u.Name).HasMaxLength(200).IsRequired(); + builder.Property(u => u.Username).HasMaxLength(40).IsRequired(); + builder.HasIndex(u => u.Username).IsUnique(); + builder.Property(u => u.Role) .HasConversion() .HasMaxLength(32) diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611100000_AddUserSuspendedUntil.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611100000_AddUserSuspendedUntil.cs new file mode 100644 index 0000000..ba61b03 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611100000_AddUserSuspendedUntil.cs @@ -0,0 +1,33 @@ +using Loopless.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260611100000_AddUserSuspendedUntil")] + public partial class AddUserSuspendedUntil : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SuspendedUntil", + table: "users", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SuspendedUntil", + table: "users"); + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611110000_AddUserUsername.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611110000_AddUserUsername.cs new file mode 100644 index 0000000..9892b03 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260611110000_AddUserUsername.cs @@ -0,0 +1,85 @@ +using Loopless.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(AppDbContext))] + [Migration("20260611110000_AddUserUsername")] + public partial class AddUserUsername : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Username", + table: "users", + type: "character varying(40)", + maxLength: 40, + nullable: true); + + // Backfill: slugify the display name, suffix collisions with -2, -3, … + // Keep the slug rules in sync with UsernameGenerator. + migrationBuilder.Sql(""" + WITH base AS ( + SELECT "Id", + left(trim(both '-' from lower(regexp_replace("Name", '[^a-zA-Z0-9]+', '-', 'g'))), 26) AS slug + FROM users + ), + cleaned AS ( + SELECT "Id", + CASE + WHEN slug = '' OR slug IN ( + 'me','admin','user','profile','settings','discover', + 'messages','projects','help','onboarding','login','signup') + THEN 'user' + ELSE trim(both '-' from slug) + END AS slug + FROM base + ), + numbered AS ( + SELECT "Id", slug, + ROW_NUMBER() OVER (PARTITION BY slug ORDER BY "Id") AS rn + FROM cleaned + ) + UPDATE users u + SET "Username" = CASE WHEN n.rn = 1 THEN n.slug ELSE n.slug || '-' || n.rn END + FROM numbered n + WHERE u."Id" = n."Id"; + """); + + migrationBuilder.AlterColumn( + name: "Username", + table: "users", + type: "character varying(40)", + maxLength: 40, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(40)", + oldMaxLength: 40, + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_users_Username", + table: "users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_users_Username", + table: "users"); + + migrationBuilder.DropColumn( + name: "Username", + table: "users"); + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.Designer.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.Designer.cs new file mode 100644 index 0000000..930382c --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.Designer.cs @@ -0,0 +1,1510 @@ +// +using System; +using System.Collections.Generic; +using Loopless.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Pgvector; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260613094742_AddProjectSlug")] + partial class AddProjectSlug + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Loopless.Domain.Entities.AuditTrail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NewValues") + .HasColumnType("text"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OldValues") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("EntityId"); + + b.HasIndex("OccurredAt"); + + b.ToTable("audit_trails", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ContentFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedById") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Pending"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt") + .IsDescending(); + + b.HasIndex("EntityId"); + + b.HasIndex("ReporterId"); + + b.HasIndex("ReviewedById"); + + b.HasIndex("Status"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("content_flags", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastMessageAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Participant1Id") + .HasColumnType("uuid"); + + b.Property("Participant2Id") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("LastMessageAt") + .IsDescending(); + + b.HasIndex("Participant1Id"); + + b.HasIndex("Participant2Id"); + + b.HasIndex("Participant1Id", "Participant2Id") + .IsUnique(); + + b.ToTable("conversations", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Embedding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vector") + .IsRequired() + .HasColumnType("vector(1536)"); + + b.HasKey("Id"); + + b.HasIndex("EntityType", "EntityId") + .IsUnique(); + + b.ToTable("embeddings", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.EnterpriseProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanySize") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Industry") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("TypicalNeeds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("enterprise_profiles", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.FreelancerProfile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Availability") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("GithubUsername") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HourlyRate") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("LinkedinUrl") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .HasColumnType("text"); + + b.Property("Proficiencies") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("freelancer_profiles", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.GitHubCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Sha") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "CommittedAt") + .IsDescending(false, true); + + b.HasIndex("ProjectId", "Sha") + .IsUnique(); + + b.ToTable("github_commits", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailNotificationSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileUrl") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("ConversationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionUrl") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt") + .IsDescending(false, true); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("GitHubRepoUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GitHubWebhookSecret") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("FreelancerId"); + + b.PrimitiveCollection>("RequiredSkills") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("StandupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("StandupRemindersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId") + .HasDatabaseName("ix_projects_freelancer_id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "UploadedAt") + .IsDescending(false, true); + + b.ToTable("project_files", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnterpriseId") + .HasColumnType("uuid"); + + b.Property("FreelancerId") + .HasColumnType("uuid"); + + b.Property("InitiatedBy") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("InvitedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RemovedReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EnterpriseId"); + + b.HasIndex("FreelancerId"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "EnterpriseId"); + + b.HasIndex("ProjectId", "FreelancerId"); + + b.ToTable("project_invitations", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("project_links", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectSummary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasColumnType("text"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HangfireJobId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ModelUsed") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "GeneratedAt") + .IsDescending(false, true); + + b.ToTable("project_summaries", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Status"); + + b.ToTable("project_tasks", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTaskAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedByUserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NewStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OldStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAt"); + + b.HasIndex("ChangedByUserId"); + + b.HasIndex("TaskId"); + + b.ToTable("project_task_audit_logs", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Skill", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.ToTable("skills", (string)null); + + b.HasData( + new + { + Id = "react", + Category = "Tech", + Label = "React" + }, + new + { + Id = "vue", + Category = "Tech", + Label = "Vue" + }, + new + { + Id = "typescript", + Category = "Tech", + Label = "TypeScript" + }, + new + { + Id = "nextjs", + Category = "Tech", + Label = "Next.js" + }, + new + { + Id = "nodejs", + Category = "Tech", + Label = "Node.js" + }, + new + { + Id = "dotnet", + Category = "Tech", + Label = ".NET" + }, + new + { + Id = "postgresql", + Category = "Tech", + Label = "PostgreSQL" + }, + new + { + Id = "docker", + Category = "Tech", + Label = "Docker" + }, + new + { + Id = "kubernetes", + Category = "Tech", + Label = "Kubernetes" + }, + new + { + Id = "terraform", + Category = "Tech", + Label = "Terraform" + }, + new + { + Id = "python", + Category = "Tech", + Label = "Python" + }, + new + { + Id = "ml", + Category = "Tech", + Label = "Machine Learning" + }, + new + { + Id = "figma", + Category = "Design", + Label = "Figma" + }, + new + { + Id = "ui-design", + Category = "Design", + Label = "UI Design" + }, + new + { + Id = "ux-research", + Category = "Design", + Label = "UX Research" + }, + new + { + Id = "branding", + Category = "Design", + Label = "Branding" + }, + new + { + Id = "illustration", + Category = "Design", + Label = "Illustration" + }, + new + { + Id = "motion-design", + Category = "Design", + Label = "Motion Design" + }, + new + { + Id = "copywriting", + Category = "Writing & Content", + Label = "Copywriting" + }, + new + { + Id = "content-writing", + Category = "Writing & Content", + Label = "Content Writing" + }, + new + { + Id = "seo", + Category = "Writing & Content", + Label = "SEO" + }, + new + { + Id = "technical-writing", + Category = "Writing & Content", + Label = "Technical Writing" + }, + new + { + Id = "ghostwriting", + Category = "Writing & Content", + Label = "Ghostwriting" + }, + new + { + Id = "proofreading", + Category = "Writing & Content", + Label = "Proofreading" + }, + new + { + Id = "social-media", + Category = "Marketing", + Label = "Social Media" + }, + new + { + Id = "email-marketing", + Category = "Marketing", + Label = "Email Marketing" + }, + new + { + Id = "paid-ads", + Category = "Marketing", + Label = "Paid Ads" + }, + new + { + Id = "growth-hacking", + Category = "Marketing", + Label = "Growth Hacking" + }, + new + { + Id = "analytics", + Category = "Marketing", + Label = "Analytics" + }, + new + { + Id = "video-editing", + Category = "Video & Audio", + Label = "Video Editing" + }, + new + { + Id = "animation", + Category = "Video & Audio", + Label = "Animation" + }, + new + { + Id = "podcast-editing", + Category = "Video & Audio", + Label = "Podcast Editing" + }, + new + { + Id = "voice-over", + Category = "Video & Audio", + Label = "Voice Over" + }, + new + { + Id = "project-management", + Category = "Business", + Label = "Project Management" + }, + new + { + Id = "consulting", + Category = "Business", + Label = "Consulting" + }, + new + { + Id = "accounting", + Category = "Business", + Label = "Accounting" + }, + new + { + Id = "legal", + Category = "Business", + Label = "Legal" + }, + new + { + Id = "translation", + Category = "Business", + Label = "Translation" + }, + new + { + Id = "data-entry", + Category = "Business", + Label = "Data Entry" + }, + new + { + Id = "marketing", + Category = "Enterprise", + Label = "Marketing" + }, + new + { + Id = "development", + Category = "Enterprise", + Label = "Development" + }, + new + { + Id = "cybersecurity", + Category = "Enterprise", + Label = "Cybersecurity" + }, + new + { + Id = "design", + Category = "Enterprise", + Label = "Design" + }, + new + { + Id = "creative", + Category = "Enterprise", + Label = "Creative" + }, + new + { + Id = "data-analytics", + Category = "Enterprise", + Label = "Data & Analytics" + }, + new + { + Id = "product-management", + Category = "Enterprise", + Label = "Product Management" + }, + new + { + Id = "customer-support", + Category = "Enterprise", + Label = "Customer Support" + }); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Standup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockerSentiment") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Blockers") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FreelancerId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WhatDid") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("WhatPlan") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("FreelancerId"); + + b.HasIndex("ProjectId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("standups", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAdminId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("Normal"); + + b.Property("ReporterId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("Open"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedAdminId"); + + b.HasIndex("CreatedAt") + .IsDescending(); + + b.HasIndex("ReporterId"); + + b.HasIndex("Status"); + + b.ToTable("support_tickets", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmailNotificationsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsSuspended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KeycloakId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("KeycloakId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.AuditTrail", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Actor") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ContentFlag", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "ReviewedBy") + .WithMany() + .HasForeignKey("ReviewedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Reporter"); + + b.Navigation("ReviewedBy"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Participant1") + .WithMany() + .HasForeignKey("Participant1Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Participant2") + .WithMany() + .HasForeignKey("Participant2Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Participant1"); + + b.Navigation("Participant2"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.EnterpriseProfile", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithOne("EnterpriseProfile") + .HasForeignKey("Loopless.Domain.Entities.EnterpriseProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.FreelancerProfile", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithOne("FreelancerProfile") + .HasForeignKey("Loopless.Domain.Entities.FreelancerProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.GitHubCommit", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Message", b => + { + b.HasOne("Loopless.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Conversation"); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Notification", b => + { + b.HasOne("Loopless.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectFile", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectInvitation", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Enterprise") + .WithMany() + .HasForeignKey("EnterpriseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.User", "Freelancer") + .WithMany() + .HasForeignKey("FreelancerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany("Invitations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Enterprise"); + + b.Navigation("Freelancer"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectLink", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany("Links") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectSummary", b => + { + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTask", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Assignee"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.ProjectTaskAuditLog", b => + { + b.HasOne("Loopless.Domain.Entities.User", "ChangedByUser") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Loopless.Domain.Entities.ProjectTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChangedByUser"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Standup", b => + { + b.HasOne("Loopless.Domain.Entities.User", "Freelancer") + .WithMany() + .HasForeignKey("FreelancerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Freelancer"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.SupportTicket", b => + { + b.HasOne("Loopless.Domain.Entities.User", "AssignedAdmin") + .WithMany() + .HasForeignKey("AssignedAdminId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Loopless.Domain.Entities.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedAdmin"); + + b.Navigation("Reporter"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.Project", b => + { + b.Navigation("Invitations"); + + b.Navigation("Links"); + }); + + modelBuilder.Entity("Loopless.Domain.Entities.User", b => + { + b.Navigation("EnterpriseProfile"); + + b.Navigation("FreelancerProfile"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.cs new file mode 100644 index 0000000..d735784 --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/20260613094742_AddProjectSlug.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Loopless.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProjectSlug : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Slug", + table: "projects", + type: "character varying(60)", + maxLength: 60, + nullable: false, + defaultValue: ""); + + // Backfill: slugify the title, suffix collisions with -2, -3, … + // Keep the slug rules in sync with ProjectSlugGenerator. + migrationBuilder.Sql(""" + WITH base AS ( + SELECT "Id", + left(trim(both '-' from lower(regexp_replace("Title", '[^a-zA-Z0-9]+', '-', 'g'))), 50) AS slug + FROM projects + ), + cleaned AS ( + SELECT "Id", + CASE + WHEN trim(both '-' from slug) = '' OR trim(both '-' from slug) IN ( + 'new','me','mine','discover','all','projects') + THEN 'project' + ELSE trim(both '-' from slug) + END AS slug + FROM base + ), + numbered AS ( + SELECT "Id", slug, + ROW_NUMBER() OVER (PARTITION BY slug ORDER BY "Id") AS rn + FROM cleaned + ) + UPDATE projects p + SET "Slug" = CASE WHEN n.rn = 1 THEN n.slug ELSE n.slug || '-' || n.rn END + FROM numbered n + WHERE p."Id" = n."Id"; + """); + + migrationBuilder.CreateIndex( + name: "IX_projects_Slug", + table: "projects", + column: "Slug", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_projects_Slug", + table: "projects"); + + migrationBuilder.DropColumn( + name: "Slug", + table: "projects"); + } + } +} diff --git a/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index b037c03..3c867eb 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -448,6 +448,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text[]"); + b.Property("Slug") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + b.Property("StandupEnabled") .ValueGeneratedOnAdd() .HasColumnType("boolean") @@ -476,6 +481,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OwnerId") .HasDatabaseName("ix_projects_freelancer_id"); + b.HasIndex("Slug") + .IsUnique(); + b.HasIndex("Status"); b.ToTable("projects", (string)null); @@ -1207,9 +1215,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Username") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + b.HasKey("Id"); b.HasIndex("Email") @@ -1218,6 +1234,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("KeycloakId") .IsUnique(); + b.HasIndex("Username") + .IsUnique(); + b.ToTable("users", (string)null); }); diff --git a/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs b/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs index 8924cfa..2c4b2ac 100644 --- a/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs +++ b/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs @@ -1,4 +1,5 @@ using Loopless.Application.Common.Exceptions; +using Loopless.Application.Common.Security; using Loopless.Application.Interfaces; using Loopless.Domain.Entities; using Loopless.Domain.Enums; @@ -80,6 +81,7 @@ public static async Task SeedAsync(IServiceProvider services, CancellationToken { Email = email, Name = name, + Username = await UsernameGenerator.GenerateUniqueAsync(db, name, cancellationToken), Role = UserRole.Admin, KeycloakId = keycloakId, }); diff --git a/backend/tests/Loopless.UnitTests/Admin/SuspensionTests.cs b/backend/tests/Loopless.UnitTests/Admin/SuspensionTests.cs new file mode 100644 index 0000000..0241cf7 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Admin/SuspensionTests.cs @@ -0,0 +1,209 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Features.Admin.SuspendUser; +using Loopless.Application.Features.Auth.GetCurrentUser; +using Loopless.Application.Features.Auth.Login; +using Loopless.Application.Interfaces; +using Loopless.Domain.Entities; +using Loopless.Domain.Enums; +using Loopless.Infrastructure.BackgroundJobs; +using Loopless.UnitTests.Messaging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Loopless.UnitTests.Admin; + +public class SuspensionTests +{ + private static User MakeUser(string keycloakId, UserRole role = UserRole.Freelancer) + => new() + { + Email = $"{keycloakId}@test.local", + Name = "User " + keycloakId, + Role = role, + KeycloakId = keycloakId, + }; + + private sealed class FakeKeycloakClient : IKeycloakAdminClient + { + public Task PasswordGrantAsync(string email, string password, CancellationToken ct = default) + => Task.FromResult(new TokenResponse("access", 300, "refresh", "Bearer")); + + public Task CreateUserAsync(string email, string name, string password, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task AuthorizationCodeGrantAsync(string code, string redirectUri, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RefreshTokenAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RefreshTokenPublicClientAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetUserInfoAsync(string accessToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task LogoutAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task AssignRoleAsync(string keycloakId, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RemoveRoleAsync(string keycloakId, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task SendResetPasswordEmailAsync(string keycloakId, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task ResetPasswordAsync(string keycloakId, string newPassword, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task> GetRequiredActionsAsync(string keycloakId, CancellationToken ct = default) + => Task.FromResult>([]); + public Task ClearRequiredActionAsync(string keycloakId, string action, CancellationToken ct = default) + => Task.CompletedTask; + } + + [Fact] + public async Task Suspend_WithUntil_SetsWindowAndAudits() + { + await using var db = TestAppDbContext.CreateInMemory(); + var admin = MakeUser("k-admin", UserRole.Admin); + var target = MakeUser("k-target"); + db.Users.AddRange(admin, target); + await db.SaveChangesAsync(); + + var until = DateTimeOffset.UtcNow.AddDays(7); + var handler = new SuspendUserCommandHandler(db, new FakeCurrentUser("k-admin")); + + var dto = await handler.Handle( + new SuspendUserCommand(target.Id, Suspend: true, Until: until), CancellationToken.None); + + Assert.True(dto.IsSuspended); + Assert.Equal(until, dto.SuspendedUntil); + Assert.True(target.IsSuspended); + Assert.Equal(until, target.SuspendedUntil); + Assert.Single(db.AuditTrails, a => a.Action == "User.Suspend"); + } + + [Fact] + public async Task Unsuspend_ClearsWindow() + { + await using var db = TestAppDbContext.CreateInMemory(); + var admin = MakeUser("k-admin", UserRole.Admin); + var target = MakeUser("k-target"); + target.IsSuspended = true; + target.SuspendedUntil = DateTimeOffset.UtcNow.AddDays(3); + db.Users.AddRange(admin, target); + await db.SaveChangesAsync(); + + var handler = new SuspendUserCommandHandler(db, new FakeCurrentUser("k-admin")); + var dto = await handler.Handle( + new SuspendUserCommand(target.Id, Suspend: false), CancellationToken.None); + + Assert.False(dto.IsSuspended); + Assert.Null(dto.SuspendedUntil); + Assert.Null(target.SuspendedUntil); + } + + [Fact] + public async Task Login_WhileSuspendedIndefinitely_Throws() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser("k-user"); + user.IsSuspended = true; + db.Users.Add(user); + await db.SaveChangesAsync(); + + var handler = new LoginQueryHandler(db, new FakeKeycloakClient()); + + await Assert.ThrowsAsync(() => + handler.Handle(new LoginQuery(user.Email, "pw"), CancellationToken.None)); + } + + [Fact] + public async Task Login_WhileSuspendedWithFutureWindow_ThrowsWithEndDate() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser("k-user"); + user.IsSuspended = true; + user.SuspendedUntil = DateTimeOffset.UtcNow.AddDays(2); + db.Users.Add(user); + await db.SaveChangesAsync(); + + var handler = new LoginQueryHandler(db, new FakeKeycloakClient()); + + var ex = await Assert.ThrowsAsync(() => + handler.Handle(new LoginQuery(user.Email, "pw"), CancellationToken.None)); + Assert.Contains("suspended until", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Login_AfterWindowExpired_LiftsSuspensionAndSucceeds() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser("k-user"); + user.IsSuspended = true; + user.SuspendedUntil = DateTimeOffset.UtcNow.AddMinutes(-5); + db.Users.Add(user); + await db.SaveChangesAsync(); + + var handler = new LoginQueryHandler(db, new FakeKeycloakClient()); + var response = await handler.Handle(new LoginQuery(user.Email, "pw"), CancellationToken.None); + + Assert.Equal("access", response.AccessToken); + Assert.False(user.IsSuspended); + Assert.Null(user.SuspendedUntil); + } + + [Fact] + public async Task GetCurrentUser_WhileSuspended_Throws() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser("k-user"); + user.IsSuspended = true; + db.Users.Add(user); + await db.SaveChangesAsync(); + + var handler = new GetCurrentUserQueryHandler(db, new FakeCurrentUser("k-user")); + + await Assert.ThrowsAsync(() => + handler.Handle(new GetCurrentUserQuery(), CancellationToken.None)); + } + + [Fact] + public async Task ExpiryJob_LiftsOnlyExpiredSuspensions() + { + await using var db = TestAppDbContext.CreateInMemory(); + var expired = MakeUser("k-expired"); + expired.IsSuspended = true; + expired.SuspendedUntil = DateTimeOffset.UtcNow.AddHours(-1); + var active = MakeUser("k-active"); + active.IsSuspended = true; + active.SuspendedUntil = DateTimeOffset.UtcNow.AddHours(1); + var indefinite = MakeUser("k-indefinite"); + indefinite.IsSuspended = true; + db.Users.AddRange(expired, active, indefinite); + await db.SaveChangesAsync(); + + var job = new SuspensionExpiryJob(db, NullLogger.Instance); + await job.RunAsync(); + + Assert.False(expired.IsSuspended); + Assert.Null(expired.SuspendedUntil); + Assert.True(active.IsSuspended); + Assert.True(indefinite.IsSuspended); + } + + [Fact] + public async Task Validator_RejectsPastUntil_AndUntilOnUnsuspend() + { + var validator = new SuspendUserCommandValidator(); + + var past = await validator.ValidateAsync( + new SuspendUserCommand(Guid.NewGuid(), Suspend: true, Until: DateTimeOffset.UtcNow.AddMinutes(-1))); + Assert.False(past.IsValid); + + var onUnsuspend = await validator.ValidateAsync( + new SuspendUserCommand(Guid.NewGuid(), Suspend: false, Until: DateTimeOffset.UtcNow.AddDays(1))); + Assert.False(onUnsuspend.IsValid); + + var future = await validator.ValidateAsync( + new SuspendUserCommand(Guid.NewGuid(), Suspend: true, Until: DateTimeOffset.UtcNow.AddDays(1))); + Assert.True(future.IsValid); + + var indefinite = await validator.ValidateAsync( + new SuspendUserCommand(Guid.NewGuid(), Suspend: true)); + Assert.True(indefinite.IsValid); + } +} diff --git a/backend/tests/Loopless.UnitTests/Auth/ForceChangePasswordTests.cs b/backend/tests/Loopless.UnitTests/Auth/ForceChangePasswordTests.cs new file mode 100644 index 0000000..ada55ad --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Auth/ForceChangePasswordTests.cs @@ -0,0 +1,222 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Features.Auth.ForceChangePassword; +using Loopless.Application.Features.Auth.Login; +using Loopless.Application.Interfaces; +using Loopless.Domain.Entities; +using Loopless.Domain.Enums; +using Loopless.UnitTests.Messaging; +using Xunit; + +namespace Loopless.UnitTests.Auth; + +public class ForceChangePasswordTests +{ + private const string Email = "user@test.local"; + private const string OldPassword = "OldPass1"; + private const string NewPassword = "NewPass1"; + + private static User MakeUser() + => new() + { + Email = Email, + Name = "Test User", + Role = UserRole.Freelancer, + KeycloakId = "kc-1", + }; + + /// + /// Simulates Keycloak around a forced reset: the stored password only works + /// after the UPDATE_PASSWORD required action has been cleared, exactly like + /// the real direct-grant flow. + /// + private sealed class ScriptedKeycloak : IKeycloakAdminClient + { + public string StoredPassword { get; set; } = OldPassword; + public List RequiredActions { get; } = ["UPDATE_PASSWORD"]; + public List PasswordsSet { get; } = []; + public List ActionsCleared { get; } = []; + + public Task PasswordGrantAsync(string email, string password, CancellationToken ct = default) + { + if (password != StoredPassword) + throw new UnauthorizedException("Invalid email or password."); + if (RequiredActions.Count > 0) + throw new AccountSetupIncompleteException("Account setup incomplete."); + return Task.FromResult(new TokenResponse("access", 300, "refresh", "Bearer")); + } + + public Task> GetRequiredActionsAsync(string keycloakId, CancellationToken ct = default) + => Task.FromResult>([.. RequiredActions]); + + public Task ClearRequiredActionAsync(string keycloakId, string action, CancellationToken ct = default) + { + ActionsCleared.Add(action); + RequiredActions.RemoveAll(a => string.Equals(a, action, StringComparison.OrdinalIgnoreCase)); + return Task.CompletedTask; + } + + public Task ResetPasswordAsync(string keycloakId, string newPassword, CancellationToken ct = default) + { + PasswordsSet.Add(newPassword); + StoredPassword = newPassword; + return Task.CompletedTask; + } + + public Task CreateUserAsync(string email, string name, string password, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task AuthorizationCodeGrantAsync(string code, string redirectUri, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RefreshTokenAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RefreshTokenPublicClientAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task GetUserInfoAsync(string accessToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task LogoutAsync(string refreshToken, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task AssignRoleAsync(string keycloakId, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task RemoveRoleAsync(string keycloakId, UserRole role, CancellationToken ct = default) + => throw new NotImplementedException(); + public Task SendResetPasswordEmailAsync(string keycloakId, CancellationToken ct = default) + => throw new NotImplementedException(); + } + + [Fact] + public async Task Login_WithPendingUpdatePassword_SignalsPasswordChangeRequired() + { + await using var db = TestAppDbContext.CreateInMemory(); + db.Users.Add(MakeUser()); + await db.SaveChangesAsync(); + + var handler = new LoginQueryHandler(db, new ScriptedKeycloak()); + + await Assert.ThrowsAsync(() => + handler.Handle(new LoginQuery(Email, OldPassword), CancellationToken.None)); + } + + [Fact] + public async Task Login_WithOtherPendingAction_StaysUnauthorized() + { + await using var db = TestAppDbContext.CreateInMemory(); + db.Users.Add(MakeUser()); + await db.SaveChangesAsync(); + + var keycloak = new ScriptedKeycloak(); + keycloak.RequiredActions.Clear(); + keycloak.RequiredActions.Add("VERIFY_EMAIL"); + + var handler = new LoginQueryHandler(db, keycloak); + + await Assert.ThrowsAsync(() => + handler.Handle(new LoginQuery(Email, OldPassword), CancellationToken.None)); + } + + [Fact] + public async Task ChangePassword_HappyPath_SetsPasswordClearsActionAndLogsIn() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser(); + db.Users.Add(user); + await db.SaveChangesAsync(); + + var keycloak = new ScriptedKeycloak(); + var handler = new ForceChangePasswordCommandHandler(db, keycloak); + + var response = await handler.Handle( + new ForceChangePasswordCommand(Email, OldPassword, NewPassword), CancellationToken.None); + + Assert.Equal("access", response.AccessToken); + Assert.Equal(user.Id, response.User.Id); + Assert.Equal([NewPassword], keycloak.PasswordsSet); + Assert.Equal(["UPDATE_PASSWORD"], keycloak.ActionsCleared); + Assert.Equal(NewPassword, keycloak.StoredPassword); + Assert.Single(db.AuditTrails, a => a.Action == "User.PasswordChange"); + } + + [Fact] + public async Task ChangePassword_WrongCurrentPassword_RejectsWithoutChanging() + { + await using var db = TestAppDbContext.CreateInMemory(); + db.Users.Add(MakeUser()); + await db.SaveChangesAsync(); + + var keycloak = new ScriptedKeycloak(); + var handler = new ForceChangePasswordCommandHandler(db, keycloak); + + await Assert.ThrowsAsync(() => + handler.Handle( + new ForceChangePasswordCommand(Email, "WrongPass1", NewPassword), CancellationToken.None)); + + Assert.Empty(keycloak.PasswordsSet); + Assert.Equal(OldPassword, keycloak.StoredPassword); + } + + [Fact] + public async Task ChangePassword_UnknownEmail_RejectsWithSameMessageAsWrongPassword() + { + await using var db = TestAppDbContext.CreateInMemory(); + + var handler = new ForceChangePasswordCommandHandler(db, new ScriptedKeycloak()); + + var ex = await Assert.ThrowsAsync(() => + handler.Handle( + new ForceChangePasswordCommand("nobody@test.local", OldPassword, NewPassword), + CancellationToken.None)); + Assert.Equal("Current password is incorrect.", ex.Message); + } + + [Fact] + public async Task ChangePassword_SuspendedUser_IsForbidden() + { + await using var db = TestAppDbContext.CreateInMemory(); + var user = MakeUser(); + user.IsSuspended = true; + db.Users.Add(user); + await db.SaveChangesAsync(); + + var keycloak = new ScriptedKeycloak(); + var handler = new ForceChangePasswordCommandHandler(db, keycloak); + + await Assert.ThrowsAsync(() => + handler.Handle( + new ForceChangePasswordCommand(Email, OldPassword, NewPassword), CancellationToken.None)); + + Assert.Empty(keycloak.PasswordsSet); + } + + [Fact] + public async Task ChangePassword_WithoutPendingAction_StillWorksAsVerifiedChange() + { + await using var db = TestAppDbContext.CreateInMemory(); + db.Users.Add(MakeUser()); + await db.SaveChangesAsync(); + + var keycloak = new ScriptedKeycloak(); + keycloak.RequiredActions.Clear(); + + var handler = new ForceChangePasswordCommandHandler(db, keycloak); + var response = await handler.Handle( + new ForceChangePasswordCommand(Email, OldPassword, NewPassword), CancellationToken.None); + + Assert.Equal("access", response.AccessToken); + Assert.Equal(NewPassword, keycloak.StoredPassword); + } + + [Fact] + public async Task Validator_EnforcesPolicyAndDifference() + { + var validator = new ForceChangePasswordCommandValidator(); + + Assert.False((await validator.ValidateAsync( + new ForceChangePasswordCommand(Email, OldPassword, "short1A"))).IsValid); + Assert.False((await validator.ValidateAsync( + new ForceChangePasswordCommand(Email, OldPassword, "alllowercase1"))).IsValid); + Assert.False((await validator.ValidateAsync( + new ForceChangePasswordCommand(Email, OldPassword, OldPassword))).IsValid); + Assert.False((await validator.ValidateAsync( + new ForceChangePasswordCommand("not-an-email", OldPassword, NewPassword))).IsValid); + Assert.True((await validator.ValidateAsync( + new ForceChangePasswordCommand(Email, OldPassword, NewPassword))).IsValid); + } +} diff --git a/backend/tests/Loopless.UnitTests/Files/UploadFileCommandValidatorTests.cs b/backend/tests/Loopless.UnitTests/Files/UploadFileCommandValidatorTests.cs new file mode 100644 index 0000000..8cfc007 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Files/UploadFileCommandValidatorTests.cs @@ -0,0 +1,64 @@ +using Loopless.Application.Features.Files.Upload; +using Xunit; + +namespace Loopless.UnitTests.Files; + +public class UploadFileCommandValidatorTests +{ + private readonly UploadFileCommandValidator _validator = new(); + + private static UploadFileCommand MakeCommand(string fileName, string contentType, long size = 1024) + => new(Guid.NewGuid(), fileName, contentType, size, Stream.Null); + + [Theory] + [InlineData("notes.txt", "text/plain")] + [InlineData("README.md", "text/markdown")] + [InlineData("README.md", "text/plain")] // browsers report .md as text/plain + [InlineData("README.md", "")] // ...or with no content type at all + [InlineData("README.md", "application/octet-stream")] + [InlineData("data.csv", "text/csv")] + [InlineData("config.json", "application/json")] + [InlineData("report.pdf", "application/pdf")] + [InlineData("chart.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")] + [InlineData("deck.pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation")] + [InlineData("photo.PNG", "image/png")] // extension check is case-insensitive + public async Task Accepts_supported_files(string fileName, string contentType) + { + var result = await _validator.ValidateAsync(MakeCommand(fileName, contentType)); + Assert.True(result.IsValid, string.Join("; ", result.Errors)); + } + + [Theory] + [InlineData("page.html", "text/html")] + [InlineData("icon.svg", "image/svg+xml")] + [InlineData("script.js", "text/javascript")] + [InlineData("tool.exe", "application/octet-stream")] // generic type can't bypass the extension check + [InlineData("archive.rar", "application/vnd.rar")] + public async Task Rejects_unsupported_files(string fileName, string contentType) + { + var result = await _validator.ValidateAsync(MakeCommand(fileName, contentType)); + Assert.False(result.IsValid); + } + + [Fact] + public async Task Rejects_disallowed_content_type_even_with_allowed_extension() + { + var result = await _validator.ValidateAsync(MakeCommand("notes.txt", "text/html")); + Assert.False(result.IsValid); + } + + [Fact] + public async Task Rejects_files_over_size_limit() + { + var result = await _validator.ValidateAsync( + MakeCommand("report.pdf", "application/pdf", 21 * 1024 * 1024)); + Assert.False(result.IsValid); + } + + [Fact] + public async Task Rejects_empty_files() + { + var result = await _validator.ValidateAsync(MakeCommand("report.pdf", "application/pdf", 0)); + Assert.False(result.IsValid); + } +} diff --git a/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs b/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs index 99dcac0..da8ff9c 100644 --- a/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs +++ b/backend/tests/Loopless.UnitTests/Messaging/MessagingHandlerTests.cs @@ -65,7 +65,8 @@ public async Task SendMessage_PersistsAndBroadcasts() await db.SaveChangesAsync(); var notifier = new RecordingMessagingNotifier(); - var handler = new SendMessageCommandHandler(db, new FakeCurrentUser("k-me"), notifier); + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler(db, new FakeCurrentUser("k-me"), notifier, notifications); var dto = await handler.Handle(new SendMessageCommand(conv.Id, "hello"), CancellationToken.None); @@ -76,6 +77,85 @@ public async Task SendMessage_PersistsAndBroadcasts() Assert.NotNull(db.Conversations.Single().LastMessageAt); } + [Fact] + public async Task SendMessage_NotifiesRecipientOnly() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "hello"), CancellationToken.None); + + var sent = Assert.Single(notifications.Sent); + Assert.Equal(other.Id, sent.UserId); + Assert.Equal("New message from Sender", sent.Title); + Assert.Equal($"/messages?c={conv.Id}", sent.ActionUrl); + } + + [Fact] + public async Task SendMessage_SkipsNotificationWhenUnreadOneExistsForConversation() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + db.Notifications.Add(new Notification + { + UserId = other.Id, + Title = "New message from Sender", + Body = "earlier", + IsRead = false, + ActionUrl = $"/messages?c={conv.Id}", + }); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "again"), CancellationToken.None); + + Assert.Empty(notifications.Sent); + } + + [Fact] + public async Task SendMessage_NotifiesAgainAfterPreviousNotificationWasRead() + { + await using var db = TestAppDbContext.CreateInMemory(); + var me = MakeUser("k-me", "Sender"); + var other = MakeUser("k-other"); + db.Users.AddRange(me, other); + var conv = new Conversation { Participant1Id = me.Id, Participant2Id = other.Id }; + db.Conversations.Add(conv); + db.Notifications.Add(new Notification + { + UserId = other.Id, + Title = "New message from Sender", + Body = "earlier", + IsRead = true, + ActionUrl = $"/messages?c={conv.Id}", + }); + await db.SaveChangesAsync(); + + var notifications = new RecordingNotificationService(); + var handler = new SendMessageCommandHandler( + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), notifications); + + await handler.Handle(new SendMessageCommand(conv.Id, "again"), CancellationToken.None); + + Assert.Single(notifications.Sent); + } + [Fact] public async Task SendMessage_RejectsNonParticipant() { @@ -89,7 +169,7 @@ public async Task SendMessage_RejectsNonParticipant() await db.SaveChangesAsync(); var handler = new SendMessageCommandHandler( - db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier()); + db, new FakeCurrentUser("k-me"), new RecordingMessagingNotifier(), new RecordingNotificationService()); await Assert.ThrowsAsync(() => handler.Handle(new SendMessageCommand(conv.Id, "x"), CancellationToken.None)); diff --git a/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs b/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs new file mode 100644 index 0000000..aaf6249 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Messaging/RecordingNotificationService.cs @@ -0,0 +1,14 @@ +using Loopless.Application.Interfaces; + +namespace Loopless.UnitTests.Messaging; + +internal sealed class RecordingNotificationService : INotificationService +{ + public List<(Guid UserId, string Title, string Body, string? ActionUrl)> Sent { get; } = []; + + public Task SendAsync(Guid userId, string title, string body, CancellationToken cancellationToken = default, string? actionUrl = null) + { + Sent.Add((userId, title, body, actionUrl)); + return Task.CompletedTask; + } +} diff --git a/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs b/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs index 5c88f53..2578917 100644 --- a/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs +++ b/backend/tests/Loopless.UnitTests/Messaging/TestAppDbContext.cs @@ -39,10 +39,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); - modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); @@ -66,6 +64,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasKey(m => m.Id); b.HasOne(m => m.Sender).WithMany().HasForeignKey(m => m.SenderId); }); + modelBuilder.Entity(b => + { + b.HasKey(n => n.Id); + b.Ignore(n => n.User); + }); + modelBuilder.Entity(b => + { + b.HasKey(a => a.Id); + b.Ignore(a => a.Actor); + }); base.OnModelCreating(modelBuilder); } diff --git a/backend/tests/Loopless.UnitTests/Users/UsernameGeneratorTests.cs b/backend/tests/Loopless.UnitTests/Users/UsernameGeneratorTests.cs new file mode 100644 index 0000000..0c37283 --- /dev/null +++ b/backend/tests/Loopless.UnitTests/Users/UsernameGeneratorTests.cs @@ -0,0 +1,55 @@ +using Loopless.Application.Common.Security; +using Loopless.Domain.Entities; +using Loopless.Domain.Enums; +using Loopless.UnitTests.Messaging; +using Xunit; + +namespace Loopless.UnitTests.Users; + +public class UsernameGeneratorTests +{ + [Theory] + [InlineData("Edi Asllani", "edi-asllani")] + [InlineData(" Vera Bunjaku ", "vera-bunjaku")] + [InlineData("J@ne D'oe!", "j-ne-d-oe")] + [InlineData("ALLCAPS", "allcaps")] + [InlineData("ме", "user")] // non-latin collapses to empty -> fallback + [InlineData("me", "user")] // reserved route segment + [InlineData("Admin", "user")] // reserved, case-insensitive via lowering + [InlineData("", "user")] + public void Slugify_ProducesUrlSafeHandles(string name, string expected) + { + Assert.Equal(expected, UsernameGenerator.Slugify(name)); + } + + [Fact] + public void Slugify_CapsLength() + { + var slug = UsernameGenerator.Slugify(new string('a', 100)); + Assert.True(slug.Length <= 26); + } + + [Fact] + public async Task GenerateUnique_SuffixesCollisions() + { + await using var db = TestAppDbContext.CreateInMemory(); + db.Users.AddRange( + new User { Email = "a@t.local", Name = "Edi Asllani", Username = "edi-asllani", Role = UserRole.Freelancer, KeycloakId = "k1" }, + new User { Email = "b@t.local", Name = "Edi Asllani", Username = "edi-asllani-2", Role = UserRole.Freelancer, KeycloakId = "k2" }); + await db.SaveChangesAsync(); + + var username = await UsernameGenerator.GenerateUniqueAsync(db, "Edi Asllani"); + + Assert.Equal("edi-asllani-3", username); + } + + [Fact] + public async Task GenerateUnique_NoCollision_UsesBareSlug() + { + await using var db = TestAppDbContext.CreateInMemory(); + + var username = await UsernameGenerator.GenerateUniqueAsync(db, "Nida Perolli"); + + Assert.Equal("nida-perolli", username); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index b3e4522..2492214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,12 @@ services: OpenAI__ChatEndpoint: "${OPENAI_CHAT_ENDPOINT:-https://api.openai.com/v1/chat/completions}" OpenAI__ChatModel: "${OPENAI_MODEL:-gpt-4o-mini}" GitHub__Token: "${GITHUB_TOKEN:-}" + # Bootstrap admin for local dev — AdminSeeder creates a Keycloak user + # (enabled, non-temporary password) + DB mirror row with Role=Admin on + # startup. Override via .env. Log in at http://localhost:3002 with these. + Seed__AdminEmail: "${SEED_ADMIN_EMAIL:-admin@loopless.local}" + Seed__AdminPassword: "${SEED_ADMIN_PASSWORD:-change_me_local}" + Seed__AdminName: "${SEED_ADMIN_NAME:-Loopless Admin}" KeyVault__Uri: "${KEYVAULT_URI:-}" Storage__BucketName: "${STORAGE_BUCKET:-loopless-uploads-dev}" Storage__Region: us-east-1 diff --git a/docs/Issues_2026-06-11.md b/docs/Issues_2026-06-11.md new file mode 100644 index 0000000..d9b7774 --- /dev/null +++ b/docs/Issues_2026-06-11.md @@ -0,0 +1,296 @@ +# Issues — 2026-06-11 + +Triage, prioritization, and detailed work plan for the 8 issues reported on 2026-06-11. +All findings below were verified against the `develop` branch as of commit `2fb61c6`. + +> **Status (2026-06-11): all 8 issues implemented on `develop`.** +> +> | # | Issue | Resolution commit | +> |---|-------|-------------------| +> | 1 | Reload crash (runtime API base URL) | fast-forward merge of `fix/frontend-api-url-env-config` (`0af43bd`) | +> | 2 | Message bell notifications + suppression on /messages | `fix(messages): create bell notification for message recipient` | +> | 3 | Admin ticket/flag notification delivery | `fix(notifications): make admin ticket/flag notification delivery diagnosable and resilient` | +> | 4 | Flags dismiss silent failure | `fix(admin): surface flag review errors and scope busy state per row` | +> | 5 | Forced password change at login | `feat(auth): complete the admin-forced password change flow at login` | +> | 6 | Suspension duration + enforcement | `feat(admin): timed user suspensions with login enforcement` | +> | 7 | Username profile URLs + share button | `feat(profile): username-based profile URLs and working share button` | +> | 8 | txt/md upload allowlist + size alignment | `fix(files): accept text/markdown/csv/json uploads and align size limits` | +> +> Verified: 78 backend unit tests (19 new), 35 frontend Jest tests, `tsc --noEmit`, ESLint, full +> solution build, and a production `next build` — all green. Remaining manual verification that +> needs the deployed stack: two-browser live-notification smoke test and the Keycloak forced-reset +> round trip (admin "Reset pw" → login → change form → re-login). + +--- + +## Priority Ranking (most → least important) + +| # | Priority | Issue | Severity rationale | +|---|----------|-------|--------------------| +| 1 | **P0 — Critical** | App breaks on page reload while logged in (projects/messages/profile never load) | Blocks every logged-in user on every page in the deployed environment. Also the hidden root cause behind parts of issues #2 and #3. | +| 2 | **P1 — High** | Message notifications not delivered in real time | Core product flow (messaging) appears broken; no notification record is ever created for new messages. | +| 3 | **P1 — High** | Admin doesn't receive help/support ticket notifications | Admin workflow broken; backend already sends them — delivery path is dead (same SignalR root cause as #1) + 30 s cache. | +| 4 | **P1 — High** | Flags page Dismiss button "doesn't work" | Admin moderation blocked. Wiring is complete in code; failure is silent (no error UI) — repro-first fix. | +| 5 | **P2 — Medium** | Forced password change at next login has no frontend flow | Security-relevant; admin feature exists but the user-facing half is missing, so a forced reset silently does nothing in our UI. | +| 6 | **P2 — Medium** | Suspension has no duration (admin must pick how long) | Feature gap; suspension today is a permanent boolean with no auto-expiry and no login enforcement check. | +| 7 | **P2 — Medium** | Profile URL exposes user GUID + Share button dead | Privacy/professionalism issue (ID enumeration surface); share button has no handler at all. | +| 8 | **P3 — Low** | .txt / .md (and other) files rejected at project file upload | Small allowlist gap in one validator + a frontend/backend max-size mismatch. | + +**Sequencing note:** Issue #1 must land first. Its root cause (API base URL resolved at build time instead of runtime) also kills the SignalR `NotificationsHub`/`MessagingHub` connections in the deployed environment, which is the delivery half of issues #2 and #3. Fixing #2/#3 before #1 would be unverifiable. + +--- + +## Issue 1 — P0: App breaks on reload while logged in + +### Symptom +Reloading any page while logged in leaves the app stuck: projects, messages, and profile never load. Only closing the tab and logging in again recovers. + +### Root cause (confirmed) +The API base URL is captured **at module-initialization time** from a **build-time** env var that is not set in the production image, so every HTTP client and SignalR connection falls back to `http://localhost:8080`: + +| File | Line(s) | Problem | +|------|---------|---------| +| `frontend/client/lib/api/client.ts` | 16–17 | `process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"` baked in at import | +| `frontend/client/lib/auth/token.ts` | 53 | same — token refresh calls go to the wrong host | +| `frontend/client/lib/signalr/messagingConnection.ts` | 13–14 | same — hub connects to wrong host | +| `frontend/client/lib/signalr/notificationsConnection.ts` | 13–14 | same — hub connects to wrong host | + +Auth state itself is fine: `stores/authStore.ts` persists tokens via Zustand `persist` (`loopless.auth` in localStorage) and rehydrates correctly; `AuthGuard` passes. But every subsequent request (including token refresh) targets the wrong origin and fails/hangs, so all queries spin forever. Re-login "fixes" it only because the login flow happens to work until the next reload. + +**The fix already exists** on branch `fix/frontend-api-url-env-config` (commits `3e9142e`, `1306d4a`, `0af43bd`, pushed to origin): it introduces `frontend/client/lib/clientEnv.ts` with an `apiBaseUrl()` function that resolves `window.__ENV` (runtime config injected by the Docker entrypoint via `/__env.js`) → server env → build-time fallback, and switches all four call sites to it. **It is not merged into `develop`** (verified: `clientEnv.ts` missing on develop). + +### Work plan +1. Open a PR merging `fix/frontend-api-url-env-config` → `develop`. Resolve conflicts in the four files above in favor of the `apiBaseUrl()` call pattern. +2. Verify after merge that **all** producers of the base URL use the runtime resolver — grep for `NEXT_PUBLIC_API_BASE_URL` and assert the only remaining references are inside `clientEnv.ts` (as build-time fallback) and `.env.example`. +3. Verify the Docker runtime-config chain end to end: + - frontend Docker entrypoint writes `__env.js` with `apiBaseUrl`; + - the app layout loads `