Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/src/Loopless.Api/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 27 additions & 3 deletions backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<AuthResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.ProducesValidationProblem()
;

group.MapPost("/auth/github-sso", async (
[FromBody] GitHubSsoCommand command,
ISender sender,
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions backend/src/Loopless.Api/Middleware/DomainExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ public async ValueTask<bool> 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),
Expand Down
5 changes: 5 additions & 0 deletions backend/src/Loopless.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ await context.HttpContext.Response.WriteAsync(
OrphanCleanupJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
Cron.Daily);

recurringJobs.AddOrUpdate<SuspensionExpiryJob>(
SuspensionExpiryJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
Cron.Hourly());
}

app.Run();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Loopless.Application.Common.Exceptions;

/// <summary>
/// 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.
/// </summary>
public sealed class AccountSetupIncompleteException(string message) : Exception(message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Loopless.Application.Common.Exceptions;

/// <summary>
/// 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.
/// </summary>
public sealed class PasswordChangeRequiredException(string message) : Exception(message);
3 changes: 3 additions & 0 deletions backend/src/Loopless.Application/Common/Mapping/UserMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand All @@ -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))]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Text.RegularExpressions;
using Loopless.Application.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Common.Security;

/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Domain.Entities;

namespace Loopless.Application.Common.Security;

public static class SuspensionGuard
{
/// <summary>
/// Enforces account suspension on a tracked <see cref="User"/>. 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
/// <see cref="ForbiddenException"/> while the suspension is still active.
/// </summary>
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.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Text.RegularExpressions;
using Loopless.Application.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Common.Security;

/// <summary>
/// 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.
/// </summary>
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<string> 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;
}
}
1 change: 1 addition & 0 deletions backend/src/Loopless.Application/DTOs/AdminUserDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public sealed record AdminUserDto(
DateTimeOffset JoinedAt,
DateTimeOffset? LastActivityAt,
bool IsSuspended,
DateTimeOffset? SuspendedUntil,
bool IsDeleted);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace Loopless.Application.DTOs;

public sealed record DiscoverProjectDto(
Guid Id,
string Slug,
string Title,
string? Description,
string OwnerName,
Expand Down
1 change: 1 addition & 0 deletions backend/src/Loopless.Application/DTOs/MatchDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs;

public sealed record MatchDto(
Guid UserId,
string Username,
string Name,
string? AvatarUrl,
UserRole Role,
Expand Down
1 change: 1 addition & 0 deletions backend/src/Loopless.Application/DTOs/ProjectDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs;

public sealed record ProjectDto(
Guid Id,
string Slug,
Guid FreelancerId,
string FreelancerName,
string Title,
Expand Down
1 change: 1 addition & 0 deletions backend/src/Loopless.Application/DTOs/PublicUserDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Loopless.Application.DTOs;

public sealed record PublicUserDto(
Guid Id,
string Username,
string Name,
UserRole Role,
string? AvatarUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public async Task<AdminUserDto> Handle(ChangeUserRoleCommand request, Cancellati
target.CreatedAt,
null,
target.IsSuspended,
target.SuspendedUntil,
target.IsDeleted);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public async Task<PagedResult<AdminUserDto>> Handle(
.Where(n => n.UserId == u.Id)
.Max(n => (DateTimeOffset?)n.CreatedAt),
u.IsSuspended,
u.SuspendedUntil,
u.IsDeleted,
})
.ToListAsync(cancellationToken);
Expand All @@ -61,6 +62,7 @@ public async Task<PagedResult<AdminUserDto>> Handle(
u.CreatedAt,
new DateTimeOffset?[] { u.MessageLast, u.StandupLast, u.NotificationLast }.Max(),
u.IsSuspended,
u.SuspendedUntil,
u.IsDeleted))
.ToList();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@

namespace Loopless.Application.Features.Admin.SuspendUser;

public sealed record SuspendUserCommand(Guid UserId, bool Suspend)
/// <param name="Until">End of the suspension window; null with Suspend=true means indefinite.</param>
public sealed record SuspendUserCommand(Guid UserId, bool Suspend, DateTimeOffset? Until = null)
: IRequest<AdminUserDto>;
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ public async Task<AdminUserDto> 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
{
Expand All @@ -61,6 +62,7 @@ public async Task<AdminUserDto> Handle(
target.CreatedAt,
null,
target.IsSuspended,
target.SuspendedUntil,
target.IsDeleted);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,15 @@ public sealed class SuspendUserCommandValidator : AbstractValidator<SuspendUserC
public SuspendUserCommandValidator()
{
RuleFor(x => 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.");
}
}
Loading
Loading